From 761e528e150ac84c6930f75fc650340ad0c433ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 14:35:27 +0200 Subject: [PATCH 01/14] Design: open repositories on another machine over ssh --- ...-15-remote-repositories-over-ssh-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md diff --git a/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md new file mode 100644 index 0000000..c6f5428 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md @@ -0,0 +1,129 @@ +# Repositories on another machine, over ssh + +## Goal + +Open a git repository that lives on another machine and see its history exactly as +gites shows a local one: branches, tags, remote branches, commits, the details panel +and diffs. gites shows the state of that folder on that machine, including its local +branches and which branch is checked out there, not the state of its origin. + +Only history is shown, as for local repositories today. Uncommitted changes are not +part of this work. + +Linux and macOS are the platforms that matter. Windows must work, but may be slow. + +## How the user opens one + +- Command line: `gites myserver:/srv/app` or `gites user@myserver:/srv/app`. + `myserver` can be any host name from `~/.ssh/config`, so ports, user names and keys + set there apply. +- In the window: the repository menu (`o`) and the welcome screen get an entry + "Open on another machine…" with one text field for `[user@]host:path`. +- Remote repositories go into the recent list like local ones, written as + `myserver:/srv/app`. + +## Where a repository is + +The Rust side stores the open repository as one of two kinds: + +- a local folder, as today; +- an ssh target (`myserver` or `user@myserver`) and a path on that machine. + +One function turns text into a location. The text means a remote repository when it +has the form `[user@]host:path`, where `host` contains no `/` or `\` and is longer than +one character, and no local folder with exactly that name exists. Every other text is a +local folder. So `C:\code\x` and `C:/code/x` stay local, `./a:b` stays local, and a +folder in the current folder named `notes:2024` stays local when it exists. + +A path on the server that starts with `~/` is handed to the server's shell with the +`~` outside the quotes, so the shell expands it to the home folder there. + +## Running git + +`git()` in `src-tauri/src/git.rs` takes the repository's location instead of a folder. + +- Local: `git -C `, as today, with the Windows flag that keeps console + windows from opening. +- Remote: the program `ssh`, with these options, then the target, then one command + string for the server's shell: + + ``` + ssh -o BatchMode=yes -o ConnectTimeout=10 + -o ControlMaster=auto -o ControlPath= -o ControlPersist=10m + user@myserver "git -C '/srv/app' 'log' '--format=…' …" + ``` + + - `BatchMode=yes`: ssh never asks for a password or a yes/no answer, because gites + has no terminal to ask in. Keys and the ssh agent work. + - `ConnectTimeout=10`: a server that does not answer gives an error after 10 seconds + instead of a window that waits for ever. + - `ControlMaster`, `ControlPath`, `ControlPersist`: the first command opens one + connection and leaves it open; later commands go through it and cost about one + network round trip each instead of a new login. The connection closes 10 minutes + after the last command. The socket is `gites-%C` in the user's runtime folder + (`/run/user/` on Linux), or in `/tmp` where there is none; `%C` is a hash ssh + makes from the host, port and user, so the path stays short. + - On Windows, ssh cannot share connections, so these three options are left out and + every command opens a new connection. +- Quoting: every word of the git command is put in single quotes, and a `'` inside a + word is written as `'\''`. This works in sh, bash, zsh and fish, so a file name with + spaces, quotes, `$`, `;` or a line break reaches git unchanged and cannot run + anything else on the server. + +Nothing above `git()` changes: loading the graph, the details panel, diffs and the +reload when the window gets focus all run through it. + +## Names and saved data + +A remote repository's name is `:`, where the top folder is what +`git rev-parse --show-toplevel` prints on the server. That name is used: + +- in the window title and the header; +- in the recent list in `settings.json`; +- as the key of the repository's filters in `filters.json`, so filters are remembered + for remote repositories just like for local ones. + +## Start-up + +A remote location given on the command line becomes the open repository at once. gites +does not test the connection before the window appears, so a slow server never delays +the window. If the server cannot be reached, the first load fails and the window shows +the error screen, as it does today for a local path that is not a repository. + +## Errors + +ssh exits with code 255 when the connection fails, and with git's own exit code +otherwise. That tells a connection problem from a git error: + +| What happened | What gites shows | +|---|---| +| Connection failed (exit code 255) | "could not connect to myserver:" and ssh's message | +| …and ssh says "Host key verification failed" | also: "Run `ssh myserver` once in a terminal to accept the server's key." | +| …and ssh says "Permission denied" | also: "gites logs in with keys or your ssh agent only; check that `ssh myserver` works without a password." | +| The server does not answer within 10 seconds | the connection error above, with ssh's timeout message | +| git fails on the server, for example "not a git repository" | git's message, as today | + +If the connection drops while a repository is open, the next command shows its error +as a message at the bottom of the window and the graph that is already on screen +stays. The next reload connects again by itself. + +## Tests + +- Reading locations: `host:/path`, `user@host:/path`, `host:~/x`, `C:\x`, `C:/x`, a + plain relative path, and an existing local folder with a `:` in its name. +- Quoting: words with spaces, `'`, `"`, `$`, `;`, `*` and a line break are quoted, run + through a real `sh -c`, and come out unchanged. +- End to end without a server: for tests, the ssh program can be replaced by a small + script that drops the ssh options and runs the command string with `sh -c` on this + machine, the way a server's shell would. With it, the existing tests for loading a + repository, commit details and diffs also run against a "remote" location, including + files with awkward names. CI runs these. +- By hand, before release: open a repository on a real server, check the graph, a + commit's details and a diff, then wait for the shared connection to close and reload. + +## Not part of this work + +- Uncommitted changes on the remote machine. +- Passwords and other questions ssh might ask. +- `ssh://` addresses; ports and users go in `~/.ssh/config` or `user@host`. +- Closing the shared connection when gites quits; it closes itself after 10 minutes. -- 2.54.0 From ddb05980e0f6396ccfd44545c8f928fcc84b1fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 14:44:17 +0200 Subject: [PATCH 02/14] Plan: open repositories on another machine over ssh --- ...2026-10-15-remote-repositories-over-ssh.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md diff --git a/docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md b/docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md new file mode 100644 index 0000000..aa91e73 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md @@ -0,0 +1,142 @@ +# Repositories on another machine, over ssh: Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Open a git repository on another machine with `gites host:/path` or from the window, and show its history exactly as for a local repository, by running every git command through ssh. + +**Architecture:** A new `Location` value (local folder, or ssh target plus path) replaces the folder that `git()`, `git::load`, `details::load` and `diff::load` take today. `git()` runs git directly for a local location, and `ssh … "git -C '' '' …"` for a remote one, with a shared ssh connection. Nothing above `git()` changes except how the location is chosen at start-up and when opening a repository; the window gets a text field to type `host:/path`. + +**Tech Stack:** Rust (Tauri 2 commands), Vue 3 + TypeScript, OpenSSH client, git. + +**Spec:** `docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md` + +## Global Constraints + +- Remote text form: `[user@]host:path`; `host` has no `/` or `\`, is longer than one character, and no local folder with exactly the whole text as its name exists (relative to the current folder). Everything else is local. +- A remote path starting with `~/` keeps the `~` outside the quotes. +- ssh options, in this order: `-o BatchMode=yes -o ConnectTimeout=10 -o ControlMaster=auto -o ControlPath= -o ControlPersist=10m`, then the target, then one command string. On Windows the three `Control…` options are left out. +- Socket: `/gites-%C`, where the runtime folder is `dirs::runtime_dir()`, or `/tmp` when that is `None`. +- Quoting: each word in single quotes; a `'` inside becomes `'\''`. +- Remote repository name: `:`. +- ssh exit code 255 means a connection error. Messages, exactly: + - `could not connect to : ` + - if stderr contains `Host key verification failed`, add on a new line: ``Run `ssh ` once in a terminal to accept the server's key.`` + - if stderr contains `Permission denied`, add on a new line: ``gites logs in with keys or your ssh agent only; check that `ssh ` works without a password.`` +- The ssh program is `ssh`, unless the environment variable `GITES_SSH` names another program (used by tests). +- Window text: the menu and welcome entry reads `Open on another machine…`; the text field's placeholder is `user@host:/path/to/repo`. +- Commit messages describe the change only: no session links, no co-author lines. + +## Review Focus + +- A remote path with spaces, such as `myserver:/srv/my app`, must open: covered in Task 2's end-to-end test. +- `ssh` missing on this machine: the error must say `could not run ssh: …`, not crash or hang; test in Task 2. +- A file name with `'`, `$`, `;` and a space must give the same diff remotely as locally: Task 2's end-to-end test. +- A `host:path` typed with surrounding spaces in the window's text field must open: the field trims it; test in Task 4 by hand (the window code has no unit-test surface for this component). +- A server whose login shell prints text for non-interactive sessions would corrupt git's output. This cannot be fixed in gites; it is listed so a reviewer checks that nothing in the code makes it worse (for example, no `-t` option is passed to ssh). + +--- + +### Task 1: Location: reading `host:path`, quoting, the remote command + +**Files:** +- Create: `src-tauri/src/location.rs` +- Modify: `src-tauri/src/lib.rs` (add `mod location;`) + +**Interfaces:** +- Produces: + - `#[derive(Clone, Debug, PartialEq)] pub enum Location { Local(PathBuf), Ssh { target: String, path: String } }` + - `pub fn parse(text: &str, cwd: &Path) -> Location` + - `impl Location { pub fn name(&self) -> String }`: local → the path as text (`to_string_lossy`); remote → `"{target}:{path}"`. + - `impl Location { pub fn with_path(&self, path: &str) -> Location }`: same kind and target, another path (`Local(PathBuf::from(path))` for local). + - `pub fn quote(word: &str) -> String` + - `pub fn remote_command(path: &str, args: &[&str]) -> String`: `git -C `, every word quoted with `quote`, except that a path starting with `~/` is written `~/` followed by the quoted rest. + +- [ ] **Step 1: Write the failing tests** in `location.rs` under `#[cfg(test)] mod tests`: + - `reads_remote_locations`: `parse("myserver:/srv/app", cwd)` is `Ssh { target: "myserver", path: "/srv/app" }`; `parse("ada@myserver:~/x", cwd)` is `Ssh { target: "ada@myserver", path: "~/x" }`. + - `keeps_local_paths_local`: `C:\code\x`, `C:/code/x`, `./a:b`, `relative/dir` and `/abs/dir` all give `Local(PathBuf::from(text))`. + - `keeps_an_existing_local_folder_with_a_colon_local`: create `/notes:2024`; `parse("notes:2024", &temp)` is `Local(PathBuf::from("notes:2024"))`; with that folder removed it is `Ssh { target: "notes", path: "2024" }`. + - `names_locations`: `Ssh{…}.name()` is `"myserver:/srv/app"`. + - `quoted_words_survive_a_shell`: for each of `plain`, `with space`, `it's`, `"double"`, `$HOME`, `a;b`, `*`, `line\nbreak`: `sh -c "printf %s "` prints exactly `word`. + - `builds_the_remote_command`: `remote_command("/srv/my app", &["log", "--format=%H"])` is `git -C '/srv/my app' 'log' '--format=%H'`; `remote_command("~/x y", &["status"])` is `git -C ~/'x y' 'status'`. + +- [ ] **Step 2: Add stubs** for every function in Interfaces (returning `Location::Local(PathBuf::new())` / `String::new()`) so the tests compile, then run `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: the tests FAIL on their assertions. + +- [ ] **Step 3: Implement** the functions in `location.rs` following Global Constraints. + +- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: PASS. + +- [ ] **Step 5: Commit** `src-tauri/src/location.rs src-tauri/src/lib.rs` with message `Read host:path locations and quote words for a server's shell`. + +### Task 2: Run git through ssh, and pass locations everywhere + +**Files:** +- Modify: `src-tauri/src/git.rs` (`git`, `load`, `start_dir` and tests) +- Modify: `src-tauri/src/details.rs` (`load`) +- Modify: `src-tauri/src/diff.rs` (`load` and tests) +- Modify: `src-tauri/src/lib.rs` (`RepoDir`, every command that reads it, `open_repo`, `run`) +- Create: `src-tauri/src/test_ssh.rs` (`#[cfg(test)]` only; registered in `lib.rs` with `#[cfg(test)] mod test_ssh;`) + +**Interfaces:** +- Consumes: Task 1's `Location`, `parse`, `name`, `with_path`, `remote_command`. +- Produces: + - `pub fn git(loc: &Location, args: &[&str]) -> Result`: calls `git_via` with the program from `GITES_SSH`, or `ssh`. + - `pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result`: does the work; tests call it with another program without changing `GITES_SSH`, which other tests running at the same time rely on. + - `pub fn ssh_error(target: &str, stderr: &str) -> String` (in `git.rs`): the connection message from Global Constraints. + - `pub fn load(loc: &Location) -> Result`: `RepoData.root` is `loc.with_path().name()`; later git calls in `load` use `loc.with_path()`. + - `pub fn start_dir(arg: Option, cwd: PathBuf) -> Option`: an argument goes through `location::parse(&arg, &cwd)` and is used as is; no argument keeps today's rule (current folder if inside a repository, as `Location::Local`). + - `details::load(loc: &Location, hash: &str)`, `diff::load(loc: &Location, hash, path, old_path, force)`: same behaviour, location instead of folder. + - `struct RepoDir(Mutex>)`; `open_repo(path: String)` parses `path` with `location::parse` against the current folder. + - `test_ssh::fake_ssh() -> PathBuf`: writes once (guarded by `std::sync::Once`) an executable `sh` script to the temp folder that skips each `-o ` pair and the target, then runs `exec sh -c "$1"`; sets `GITES_SSH` to it; returns its path. + +- [ ] **Step 1: Write the failing tests** + - In `git.rs`: `explains_connection_errors`: `ssh_error("myserver", "Host key verification failed.\n")` contains `could not connect to myserver: Host key verification failed.` and ``Run `ssh myserver` once in a terminal``; with `"ada@myserver: Permission denied (publickey).\n"` it contains `gites logs in with keys or your ssh agent only`; with `"ssh: connect to host myserver port 22: Connection timed out"` it contains only the first line. + - In `git.rs`: `loads_a_repository_through_ssh`: make a repository at `/remote repo` with one commit, call `fake_ssh()`, then `load(&Location::Ssh { target: "myserver".into(), path: "/remote repo".into() })` returns one commit and `root == "myserver:/remote repo"`. + - In `git.rs`: `reports_a_missing_ssh_program`: `git_via(OsStr::new("/no/such/ssh"), &Location::Ssh{…}, &["status"])` is an `Err` starting with `could not run ssh:`. + - In `diff.rs`, module `with_a_repository`: `shows_the_same_diff_through_ssh`: a repository with a file named `it's $a; b.txt` changed in one commit; `load` through `Location::Local` and through `Location::Ssh` (with `fake_ssh()`) give equal `FileDiff`s with at least one hunk. + - Update the existing tests to pass `&Location::Local(…)` instead of a folder. + +- [ ] **Step 2: Run** `cargo test --manifest-path src-tauri/Cargo.toml` — Expected: the new tests FAIL (compile errors are not acceptable: first add stubs for `ssh_error` and the new `git`/`load` signatures). + +- [ ] **Step 3: Implement** + - `git()`: local as today (keep the Windows no-console flag on both paths). Remote: program from `GITES_SSH` or `ssh`; arguments per Global Constraints; command from `remote_command`. A failure to start gives `could not run ssh: `. Exit code 255 gives `ssh_error(target, stderr)`; other failures give the trimmed stderr, as today. + - Socket path: `dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C")`. + - Thread the `Location` through `load`, `details::load`, `diff::load`, `start_dir` and `lib.rs`. + - `run()`: pass the first argument as a `String` (`to_string_lossy`) to `start_dir`. + +- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml` and `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets` — Expected: all tests PASS; clippy shows no new warnings. + +- [ ] **Step 5: Commit** the files above with message `Run git through ssh for repositories on another machine`. + +### Task 3: Open a remote repository from the window + +**Files:** +- Modify: `src/components/RepoMenu.vue` +- Modify: `src/components/Welcome.vue` +- Modify: `src/App.vue` (`load`) + +**Interfaces:** +- Consumes: the `open_repo` command from Task 2, which now accepts `host:path` text; App's existing `openRepo(path: string)`. +- Produces: both components emit the existing `pick` event with the trimmed text typed in the field. + +- [ ] **Step 1: Add the entry.** In `RepoMenu.vue`, below "Open folder…", an item `Open on another machine…` that, when chosen (click or Enter on it), turns into a text input with placeholder `user@host:/path/to/repo`. Enter in the input emits `pick` with the trimmed text when it is not empty; Esc turns it back into the item. Keys typed in the input must not reach the menu's own key handler (no `j`/`k`/`o` actions while typing). In `Welcome.vue`, the same input next to "Choose a folder…". + +- [ ] **Step 2: Keep the graph when a reload fails.** In `App.vue`'s `load()`: when a repository is already on screen (`repo.value` is set), show the error with `showToast` and keep the graph; only when nothing is open yet, set `error.value` as today. The spec requires this for a connection that drops while a repository is open. + +- [ ] **Step 3: Run** `bun run vue-tsc --noEmit` and `bun run vitest run` — Expected: no type errors, all tests PASS. + +- [ ] **Step 4: Check in the browser** with the stand-in backend used for the earlier features (Vite dev server plus a mocked `__TAURI_INTERNALS__.invoke` whose `open_repo` records its argument): type ` myserver:/srv/app ` in each of the two places, press Enter, and confirm `open_repo` received `myserver:/srv/app`. Confirm Esc in the menu's field closes only the field. Then make the mocked `load_repo` throw and trigger a reload (focus the window): the graph stays and a message appears at the bottom. + +- [ ] **Step 5: Commit** with message `Open on another machine from the window; keep the graph when a reload fails`. + +### Task 4: README and a check against a real server + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: README.** In "What it does", a bullet: repositories on another machine open with `gites myserver:/srv/app`, or from the repository menu; git runs there through ssh, which must log in without a password. In "Use", the line `gites myserver:/srv/app # a repository on another machine, over ssh`. + +- [ ] **Step 2: Real server, by hand.** Build with `mise run dev :` against a server the user names. Check: the graph loads; a commit's details; a file's diff; reload with r; a wrong path shows git's "not a git repository" message; an unknown host shows the connection message. Report what was checked and what was not. + +- [ ] **Step 3: Run** `mise run test` and `mise run check` — Expected: PASS. + +- [ ] **Step 4: Commit** `README.md` with message `README: repositories on another machine`. -- 2.54.0 From 157a4063c733b645c6d8e0564c5c8610a944a742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:05:23 +0200 Subject: [PATCH 03/14] Read host:path locations and quote words for a server's shell --- src-tauri/src/lib.rs | 1 + src-tauri/src/location.rs | 133 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src-tauri/src/location.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 238c5eb..01cfb33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod details; mod diff; mod filters; mod git; +mod location; mod settings; use std::path::PathBuf; diff --git a/src-tauri/src/location.rs b/src-tauri/src/location.rs new file mode 100644 index 0000000..46a629d --- /dev/null +++ b/src-tauri/src/location.rs @@ -0,0 +1,133 @@ +//! Where a repository lives: a folder on this machine, or a folder on another machine +//! reached over ssh. + +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Location { + Local(PathBuf), + /// `target` is what is given to ssh, like `host` or `user@host`. + Ssh { target: String, path: String }, +} + +/// Reads `[user@]host:path` as a remote location. Everything else is local: text where the +/// part before the first `:` is a single character (a Windows drive like `C:`) or contains +/// `/` or `\`, and text that is the whole name of a folder that exists inside `cwd`. +pub fn parse(text: &str, cwd: &Path) -> Location { + let local = || Location::Local(PathBuf::from(text)); + let Some((target, path)) = text.split_once(':') else { return local() }; + if target.chars().count() <= 1 || target.contains('/') || target.contains('\\') || cwd.join(text).exists() { + return local(); + } + Location::Ssh { target: target.to_string(), path: path.to_string() } +} + +impl Location { + /// The text the user would type to open this location. + pub fn name(&self) -> String { + match self { + Location::Local(path) => path.to_string_lossy().into_owned(), + Location::Ssh { target, path } => format!("{target}:{path}"), + } + } + + /// The same machine with another folder. + pub fn with_path(&self, path: &str) -> Location { + match self { + Location::Local(_) => Location::Local(PathBuf::from(path)), + Location::Ssh { target, .. } => Location::Ssh { target: target.clone(), path: path.to_string() }, + } + } +} + +/// Wraps one word in single quotes so a shell reads it back unchanged. +pub fn quote(word: &str) -> String { + format!("'{}'", word.replace('\'', r"'\''")) +} + +/// The command line to run on the remote machine: `git -C ` followed by `args`. +/// A path starting with `~/` keeps the `~` outside the quotes so the shell still +/// replaces it with the home folder. +pub fn remote_command(path: &str, args: &[&str]) -> String { + let path = match path.strip_prefix("~/") { + Some(rest) => format!("~/{}", quote(rest)), + None => quote(path), + }; + let mut command = format!("git -C {path}"); + for arg in args { + command.push(' '); + command.push_str("e(arg)); + } + command +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn cwd() -> PathBuf { + std::env::temp_dir() + } + + #[test] + fn reads_remote_locations() { + assert_eq!( + parse("myserver:/srv/app", &cwd()), + Location::Ssh { target: "myserver".into(), path: "/srv/app".into() } + ); + assert_eq!( + parse("ada@myserver:~/x", &cwd()), + Location::Ssh { target: "ada@myserver".into(), path: "~/x".into() } + ); + } + + #[test] + fn keeps_local_paths_local() { + for text in [r"C:\code\x", "C:/code/x", "./a:b", "relative/dir", "/abs/dir"] { + assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)), "{text}"); + } + } + + #[test] + fn keeps_an_existing_local_folder_with_a_colon_local() { + let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id())); + let folder = temp.join("notes:2024"); + std::fs::create_dir_all(&folder).unwrap(); + assert_eq!(parse("notes:2024", &temp), Location::Local(PathBuf::from("notes:2024"))); + std::fs::remove_dir_all(&folder).unwrap(); + assert_eq!(parse("notes:2024", &temp), Location::Ssh { target: "notes".into(), path: "2024".into() }); + std::fs::remove_dir_all(&temp).unwrap(); + } + + #[test] + fn names_locations() { + let remote = Location::Ssh { target: "myserver".into(), path: "/srv/app".into() }; + assert_eq!(remote.name(), "myserver:/srv/app"); + assert_eq!(Location::Local(PathBuf::from("/abs/dir")).name(), "/abs/dir"); + } + + #[test] + fn changes_only_the_path() { + let remote = Location::Ssh { target: "ada@myserver".into(), path: "/srv/app".into() }; + assert_eq!(remote.with_path("/srv/other"), Location::Ssh { target: "ada@myserver".into(), path: "/srv/other".into() }); + assert_eq!(Location::Local(PathBuf::from("/a")).with_path("/b"), Location::Local(PathBuf::from("/b"))); + } + + #[test] + fn quoted_words_survive_a_shell() { + for word in ["plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "line\nbreak"] { + let out = Command::new("sh").arg("-c").arg(format!("printf %s {}", quote(word))).output().unwrap(); + assert_eq!(String::from_utf8(out.stdout).unwrap(), word); + } + } + + #[test] + fn builds_the_remote_command() { + assert_eq!( + remote_command("/srv/my app", &["log", "--format=%H"]), + "git -C '/srv/my app' 'log' '--format=%H'" + ); + assert_eq!(remote_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); + } +} -- 2.54.0 From 121fe1bc2f742b737cfe509b30735d82f24d52d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:07:31 +0200 Subject: [PATCH 04/14] Send remote commands in a form every login shell reads the same way --- src-tauri/src/location.rs | 67 +++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/location.rs b/src-tauri/src/location.rs index 46a629d..cf2497d 100644 --- a/src-tauri/src/location.rs +++ b/src-tauri/src/location.rs @@ -12,11 +12,11 @@ pub enum Location { /// Reads `[user@]host:path` as a remote location. Everything else is local: text where the /// part before the first `:` is a single character (a Windows drive like `C:`) or contains -/// `/` or `\`, and text that is the whole name of a folder that exists inside `cwd`. +/// `/` or `\`, text starting with `-` (ssh would read it as an option), and text that is the whole name of a folder that exists inside `cwd`. pub fn parse(text: &str, cwd: &Path) -> Location { let local = || Location::Local(PathBuf::from(text)); let Some((target, path)) = text.split_once(':') else { return local() }; - if target.chars().count() <= 1 || target.contains('/') || target.contains('\\') || cwd.join(text).exists() { + if target.chars().count() <= 1 || target.starts_with('-') || target.contains('/') || target.contains('\\') || cwd.join(text).exists() { return local(); } Location::Ssh { target: target.to_string(), path: path.to_string() } @@ -45,10 +45,10 @@ pub fn quote(word: &str) -> String { format!("'{}'", word.replace('\'', r"'\''")) } -/// The command line to run on the remote machine: `git -C ` followed by `args`. -/// A path starting with `~/` keeps the `~` outside the quotes so the shell still +/// The command for a shell that reads quotes the standard way: `git -C ` followed by +/// `args`. A path starting with `~/` keeps the `~` outside the quotes so the shell still /// replaces it with the home folder. -pub fn remote_command(path: &str, args: &[&str]) -> String { +pub fn sh_command(path: &str, args: &[&str]) -> String { let path = match path.strip_prefix("~/") { Some(rest) => format!("~/{}", quote(rest)), None => quote(path), @@ -61,6 +61,24 @@ pub fn remote_command(path: &str, args: &[&str]) -> String { command } +/// Wraps a command so every login shell on the server (sh, bash, zsh, fish) runs it the same +/// way. Fish reads quotes differently from the others, so the command is not sent as written: +/// every byte becomes a backslash and three octal digits, which no shell changes inside single +/// quotes, and `printf` turns them back into the command for `sh` to run. +pub fn shell_neutral(sh_command: &str) -> String { + let mut wrapped = String::from("printf '"); + for byte in sh_command.bytes() { + wrapped.push_str(&format!("\\{byte:03o}")); + } + wrapped.push_str("' | sh"); + wrapped +} + +/// The command line to give to ssh to run `git -C ` on the remote machine. +pub fn remote_command(path: &str, args: &[&str]) -> String { + shell_neutral(&sh_command(path, args)) +} + #[cfg(test)] mod tests { use super::*; @@ -89,6 +107,12 @@ mod tests { } } + #[test] + fn keeps_a_target_that_ssh_would_read_as_an_option_local() { + let text = "-oProxyCommand=x:y"; + assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text))); + } + #[test] fn keeps_an_existing_local_folder_with_a_colon_local() { let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id())); @@ -122,12 +146,41 @@ mod tests { } } + #[test] + fn wrapped_words_survive_every_login_shell() { + let words = [ + "plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "%s", "line\nbreak", "a\\b", "\\", + "a\\'b", "x\\", + ]; + let mut shells_run = Vec::new(); + for shell in ["sh", "bash", "zsh", "fish"] { + if Command::new(shell).arg("-c").arg("true").output().is_err() { + assert_ne!(shell, "sh", "sh must be installed"); + continue; + } + shells_run.push(shell); + for word in words { + let wrapper = shell_neutral(&format!("printf %s {}", quote(word))); + let out = Command::new(shell).arg("-c").arg(&wrapper).output().unwrap(); + assert_eq!(String::from_utf8(out.stdout).unwrap(), word, "{shell} with {word:?}"); + } + } + assert!(shells_run.contains(&"sh")); + } + + #[test] + fn the_remote_command_uses_only_plain_characters() { + let command = remote_command("~/x y", &["status", "it's"]); + assert!(command.starts_with("printf '") && command.ends_with("' | sh")); + assert!(command.chars().all(|c| "printf'\\0123456789 |sh".contains(c)), "{command}"); + } + #[test] fn builds_the_remote_command() { assert_eq!( - remote_command("/srv/my app", &["log", "--format=%H"]), + sh_command("/srv/my app", &["log", "--format=%H"]), "git -C '/srv/my app' 'log' '--format=%H'" ); - assert_eq!(remote_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); + assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); } } -- 2.54.0 From 7fe5a70c69ed80fda5ab1535f8fb16eeceaa3c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:09:17 +0200 Subject: [PATCH 05/14] Run git through ssh for repositories on another machine --- src-tauri/src/details.rs | 8 +-- src-tauri/src/diff.rs | 49 +++++++++---- src-tauri/src/git.rs | 140 ++++++++++++++++++++++++++++++++++---- src-tauri/src/lib.rs | 17 +++-- src-tauri/src/test_ssh.rs | 25 +++++++ 5 files changed, 199 insertions(+), 40 deletions(-) create mode 100644 src-tauri/src/test_ssh.rs diff --git a/src-tauri/src/details.rs b/src-tauri/src/details.rs index acb3862..06489d3 100644 --- a/src-tauri/src/details.rs +++ b/src-tauri/src/details.rs @@ -2,7 +2,7 @@ use crate::git::git; use serde::Serialize; -use std::path::Path; +use crate::location::Location; #[derive(Serialize, Debug, PartialEq)] pub struct ChangedFile { @@ -63,13 +63,13 @@ pub fn parse_files(name_status: &str, numstat: &str) -> Vec { files } -pub fn load(dir: &Path, hash: &str) -> Result { +pub fn load(loc: &Location, hash: &str) -> Result { // The hash comes from the window; refuse anything git could read as an option. if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!("not a commit hash: {hash}")); } let info = git( - dir, + loc, &["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"], )?; let f: Vec<&str> = info.splitn(9, '\x1f').collect(); @@ -90,7 +90,7 @@ pub fn load(dir: &Path, hash: &str) -> Result { let with = |flag: &'static str| { let mut a = args.clone(); a.insert(1, flag); - git(dir, &a) + git(loc, &a) }; let files = parse_files(&with("--name-status")?, &with("--numstat")?); diff --git a/src-tauri/src/diff.rs b/src-tauri/src/diff.rs index 0d23126..8da9780 100644 --- a/src-tauri/src/diff.rs +++ b/src-tauri/src/diff.rs @@ -2,7 +2,7 @@ use crate::git::git; use serde::Serialize; -use std::path::Path; +use crate::location::Location; /// Above this many changed lines, or this many bytes of patch, the diff is only sent /// when the user asks for it again. @@ -71,12 +71,12 @@ pub fn parse_patch(patch: &str) -> FileDiff { diff } -pub fn load(dir: &Path, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result { +pub fn load(loc: &Location, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result { // The hash comes from the window; refuse anything git could read as an option. if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!("not a commit hash: {hash}")); } - let parents = git(dir, &["rev-list", "--parents", "-n", "1", hash, "--"])?; + let parents = git(loc, &["rev-list", "--parents", "-n", "1", hash, "--"])?; // A merge is compared with its first parent, as in the preview panel's file list. let parent = parents.split_whitespace().nth(1).map(String::from); @@ -89,11 +89,11 @@ pub fn load(dir: &Path, hash: &str, path: &str, old_path: Option<&str>, force: b if let Some(old) = old_path { args.push(old); } - let patch = git(dir, &args)?; + let patch = git(loc, &args)?; let mut diff = parse_patch(&patch); if diff.binary { - let size = |spec: String| git(dir, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok()); + let size = |spec: String| git(loc, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok()); diff.old_size = parent.as_ref().and_then(|p| size(format!("{p}:{}", old_path.unwrap_or(path)))); diff.new_size = size(format!("{hash}:{path}")); } @@ -192,7 +192,7 @@ Binary files a/logo.png and b/logo.png differ #[test] fn refuses_a_hash_that_git_could_read_as_an_option() { - let err = load(Path::new("."), "--output=x", "a", None, false).unwrap_err(); + let err = load(&Location::Local(".".into()), "--output=x", "a", None, false).unwrap_err(); assert!(err.contains("not a commit hash"), "{err}"); } @@ -230,6 +230,9 @@ Binary files a/logo.png and b/logo.png differ assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() } + fn loc(&self) -> Location { + Location::Local(self.0.clone()) + } fn write(&self, path: &str, content: &[u8]) { std::fs::write(self.0.join(path), content).unwrap(); } @@ -249,7 +252,7 @@ Binary files a/logo.png and b/logo.png differ let r = Repo::new("first"); r.write("a.txt", b"one\ntwo\n"); let first = r.commit("first"); - let d = load(&r.0, &first, "a.txt", None, false).unwrap(); + let d = load(&r.loc(), &first, "a.txt", None, false).unwrap(); assert_eq!(texts(&d), vec!["+one", "+two"]); } @@ -262,14 +265,14 @@ Binary files a/logo.png and b/logo.png differ r.write("a.txt", body.replace("line 5\n", "line five\n").as_bytes()); let second = r.commit("second"); assert_eq!( - texts(&load(&r.0, &second, "a.txt", None, false).unwrap()), + texts(&load(&r.loc(), &second, "a.txt", None, false).unwrap()), vec![" line 2", " line 3", " line 4", "-line 5", "+line five", " line 6", " line 7", " line 8"] ); r.run(&["mv", "a.txt", "b.txt"]); r.write("b.txt", body.replace("line 5\n", "line five\n").replace("line 20\n", "line twenty\n").as_bytes()); let third = r.commit("third"); - let d = load(&r.0, &third, "b.txt", Some("a.txt"), false).unwrap(); + let d = load(&r.loc(), &third, "b.txt", Some("a.txt"), false).unwrap(); assert_eq!(&texts(&d)[3..5], ["-line 20", "+line twenty"]); } @@ -280,7 +283,7 @@ Binary files a/logo.png and b/logo.png differ r.commit("first"); r.run(&["mv", "a.txt", "b.txt"]); let second = r.commit("second"); - assert_eq!(load(&r.0, &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default()); + assert_eq!(load(&r.loc(), &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default()); } #[test] @@ -290,13 +293,13 @@ Binary files a/logo.png and b/logo.png differ r.commit("first"); r.write("x.bin", b"\0\x01\x02\x03\x04"); let second = r.commit("second"); - let d = load(&r.0, &second, "x.bin", None, false).unwrap(); + let d = load(&r.loc(), &second, "x.bin", None, false).unwrap(); assert!(d.binary); assert_eq!((d.old_size, d.new_size), (Some(3), Some(5))); std::fs::remove_file(r.0.join("x.bin")).unwrap(); let third = r.commit("third"); - let d = load(&r.0, &third, "x.bin", None, false).unwrap(); + let d = load(&r.loc(), &third, "x.bin", None, false).unwrap(); assert_eq!((d.old_size, d.new_size), (Some(5), None)); } @@ -313,7 +316,7 @@ Binary files a/logo.png and b/logo.png differ r.commit("main"); r.run(&["merge", "-q", "--no-edit", "side"]); let merge = r.run(&["rev-parse", "HEAD"]); - let d = load(&r.0, &merge, "side.txt", None, false).unwrap(); + let d = load(&r.loc(), &merge, "side.txt", None, false).unwrap(); assert_eq!(texts(&d), vec!["+from side"]); } @@ -323,12 +326,28 @@ Binary files a/logo.png and b/logo.png differ let body: String = (0..MAX_CHANGED_LINES + 1).map(|i| format!("{i}\n")).collect(); r.write("big.txt", body.as_bytes()); let first = r.commit("first"); - let d = load(&r.0, &first, "big.txt", None, false).unwrap(); + let d = load(&r.loc(), &first, "big.txt", None, false).unwrap(); assert_eq!(d.too_large, Some(MAX_CHANGED_LINES + 1)); assert!(d.hunks.is_empty()); - let d = load(&r.0, &first, "big.txt", None, true).unwrap(); + let d = load(&r.loc(), &first, "big.txt", None, true).unwrap(); assert_eq!(d.too_large, None); assert_eq!(texts(&d).len(), (MAX_CHANGED_LINES + 1) as usize); } + + #[test] + fn shows_the_same_diff_through_ssh() { + let r = Repo::new("ssh"); + r.write("it's $a; b.txt", b"one\ntwo\n"); + r.commit("first"); + r.write("it's $a; b.txt", b"one\nthree\n"); + let second = r.commit("second"); + crate::test_ssh::fake_ssh(); + let remote = Location::Ssh { target: "myserver".into(), path: r.0.to_string_lossy().into_owned() }; + let name = "it's $a; b.txt"; + let local = load(&r.loc(), &second, name, None, false).unwrap(); + let through_ssh = load(&remote, &second, name, None, false).unwrap(); + assert!(!local.hunks.is_empty()); + assert_eq!(local, through_ssh); + } } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 07f2141..1df7b49 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,5 +1,7 @@ use serde::Serialize; -use std::path::{Path, PathBuf}; +use crate::location::{Location, remote_command}; +use std::ffi::OsStr; +use std::path::PathBuf; use std::process::Command; #[derive(Serialize)] @@ -31,10 +33,58 @@ pub struct RepoData { head: Option, } -pub fn git(dir: &Path, args: &[&str]) -> Result { - let mut cmd = Command::new("git"); - cmd.arg("-C").arg(dir).args(args); - // gites has no console, so Windows would open a console window for every git it +/// The text to show when ssh could not reach the server. +pub fn ssh_error(target: &str, stderr: &str) -> String { + let stderr = stderr.trim(); + let mut message = format!("could not connect to {target}: {stderr}"); + if stderr.contains("Host key verification failed") { + message.push_str(&format!("\nRun `ssh {target}` once in a terminal to accept the server's key.")); + } + if stderr.contains("Permission denied") { + message.push_str(&format!( + "\ngites logs in with keys or your ssh agent only; check that `ssh {target}` works without a password." + )); + } + message +} + +/// Runs git in the folder `loc` names: here, or on another machine through ssh. +pub fn git(loc: &Location, args: &[&str]) -> Result { + // Tests swap in a script that acts like ssh. + #[cfg(test)] + if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.get() { + return git_via(fake.as_os_str(), loc, args); + } + git_via(OsStr::new("ssh"), loc, args) +} + +/// Does the work of `git`, starting the program `ssh` for a remote location. +pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result { + let mut cmd = match loc { + Location::Local(dir) => { + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + cmd + } + Location::Ssh { target, path } => { + let mut cmd = Command::new(ssh); + // Never ask for a password: gites has no terminal to ask on. + cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]); + // Later commands to the same server reuse one connection for a while. Windows ssh + // does not support this. + #[cfg(not(windows))] + { + let socket = dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C"); + cmd.args(["-o", "ControlMaster=auto", "-o"]); + cmd.arg(format!("ControlPath={}", socket.display())); + cmd.args(["-o", "ControlPersist=10m"]); + } + // `--` keeps a target from being read as an option. + cmd.arg("--").arg(target).arg(remote_command(path, args)); + cmd + } + }; + // gites has no console, so Windows would open a console window for every program it // starts. Each one flashes on screen and takes the focus, and gites reloads on focus. #[cfg(windows)] { @@ -42,16 +92,25 @@ pub fn git(dir: &Path, args: &[&str]) -> Result { const CREATE_NO_WINDOW: u32 = 0x0800_0000; cmd.creation_flags(CREATE_NO_WINDOW); } - let out = cmd.output().map_err(|e| format!("could not run git: {e}"))?; + let program = if matches!(loc, Location::Local(_)) { "git" } else { "ssh" }; + let out = cmd.output().map_err(|e| format!("could not run {program}: {e}"))?; if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + let stderr = String::from_utf8_lossy(&out.stderr); + if let Location::Ssh { target, .. } = loc + && out.status.code() == Some(255) + { + return Err(ssh_error(target, &stderr)); + } + return Err(stderr.trim().to_string()); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } -pub fn load(dir: &Path) -> Result { - let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string(); - let root_path = Path::new(&root); +pub fn load(loc: &Location) -> Result { + let top = git(loc, &["rev-parse", "--show-toplevel"])?.trim().to_string(); + let root_loc = loc.with_path(&top); + let root_path = &root_loc; + let root = root_loc.name(); let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"]) .ok() @@ -135,8 +194,12 @@ pub fn load(dir: &Path) -> Result { /// current folder if it is inside a repository. `None` means gites starts with its /// welcome screen. A path given on the command line is used even if it is not a /// repository, so the window can say what is wrong with it. -pub fn start_dir(arg: Option, cwd: PathBuf) -> Option { - arg.or_else(|| git(&cwd, &["rev-parse", "--git-dir"]).ok().map(|_| cwd)) +pub fn start_dir(arg: Option, cwd: PathBuf) -> Option { + if let Some(arg) = arg { + return Some(crate::location::parse(&arg, &cwd)); + } + let here = Location::Local(cwd); + git(&here, &["rev-parse", "--git-dir"]).ok().map(|_| here) } #[cfg(test)] @@ -153,15 +216,15 @@ mod tests { #[test] fn opens_the_path_given_on_the_command_line_even_if_it_is_not_a_repository() { let given = PathBuf::from("/no/such/folder"); - assert_eq!(start_dir(Some(given.clone()), temp_dir("arg")), Some(given)); + assert_eq!(start_dir(Some("/no/such/folder".into()), temp_dir("arg")), Some(Location::Local(given))); } #[test] fn opens_the_current_folder_when_it_is_inside_a_repository() { let dir = temp_dir("repo"); - git(&dir, &["init", "-q"]).unwrap(); + git(&Location::Local(dir.clone()), &["init", "-q"]).unwrap(); std::fs::create_dir_all(dir.join("sub")).unwrap(); - assert_eq!(start_dir(None, dir.join("sub")), Some(dir.join("sub"))); + assert_eq!(start_dir(None, dir.join("sub")), Some(Location::Local(dir.join("sub")))); let _ = std::fs::remove_dir_all(&dir); } @@ -171,4 +234,51 @@ mod tests { assert_eq!(start_dir(None, dir.clone()), None); let _ = std::fs::remove_dir_all(&dir); } + + fn run_git(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"]) + .args(args) + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); + } + + #[test] + fn explains_connection_errors() { + let e = ssh_error("myserver", "Host key verification failed.\n"); + assert!(e.contains("could not connect to myserver: Host key verification failed."), "{e}"); + assert!(e.contains("Run `ssh myserver` once in a terminal"), "{e}"); + let e = ssh_error("myserver", "ada@myserver: Permission denied (publickey).\n"); + assert!(e.contains("gites logs in with keys or your ssh agent only"), "{e}"); + assert!(e.contains("check that `ssh myserver` works without a password."), "{e}"); + let e = ssh_error("myserver", "ssh: connect to host myserver port 22: Connection timed out"); + assert_eq!(e, "could not connect to myserver: ssh: connect to host myserver port 22: Connection timed out"); + } + + #[test] + fn loads_a_repository_through_ssh() { + let base = temp_dir("remote"); + let dir = base.join("remote repo"); + std::fs::create_dir_all(&dir).unwrap(); + run_git(&dir, &["init", "-q", "-b", "main"]); + std::fs::write(dir.join("a.txt"), "one\n").unwrap(); + run_git(&dir, &["add", "-A"]); + run_git(&dir, &["commit", "-q", "-m", "first"]); + crate::test_ssh::fake_ssh(); + let path = dir.to_string_lossy().into_owned(); + let data = load(&Location::Ssh { target: "myserver".into(), path: path.clone() }).unwrap(); + assert_eq!(data.commits.len(), 1); + assert_eq!(data.root, format!("myserver:{path}")); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn reports_a_missing_ssh_program() { + let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; + let err = git_via(OsStr::new("/no/such/ssh"), &loc, &["status"]).unwrap_err(); + assert!(err.starts_with("could not run ssh:"), "{err}"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01cfb33..cabac9f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,16 +4,19 @@ mod filters; mod git; mod location; mod settings; +#[cfg(test)] +mod test_ssh; +use location::Location; use std::path::PathBuf; use std::sync::Mutex; -/// The open repository folder, from `git::start_dir`; it changes when the user opens +/// The open repository (a folder here or on another machine), from `git::start_dir`; it changes when the user opens /// another one. `None` until a repository is open: the window shows its welcome screen. -struct RepoDir(Mutex>); +struct RepoDir(Mutex>); impl RepoDir { - fn get(&self) -> Result { + fn get(&self) -> Result { self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into()) } } @@ -30,8 +33,10 @@ fn load_repo(dir: tauri::State) -> Result, String /// Switches to another repository. The current one stays open if `path` is not a repository. #[tauri::command] fn open_repo(dir: tauri::State, path: String) -> Result { - let data = git::load(&PathBuf::from(&path))?; - *dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(PathBuf::from(&path)); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let loc = location::parse(&path, &cwd); + let data = git::load(&loc)?; + *dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(loc); Ok(data) } @@ -73,7 +78,7 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let arg = std::env::args_os().nth(1).map(PathBuf::from); + let arg = std::env::args_os().nth(1).map(|a| a.to_string_lossy().into_owned()); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let path = git::start_dir(arg, cwd); diff --git a/src-tauri/src/test_ssh.rs b/src-tauri/src/test_ssh.rs new file mode 100644 index 0000000..5b433a7 --- /dev/null +++ b/src-tauri/src/test_ssh.rs @@ -0,0 +1,25 @@ +//! A stand-in for ssh, so tests can run the remote code path without a server. + +use std::path::PathBuf; +use std::sync::OnceLock; + +/// The program `git::git` starts instead of `ssh` while tests run. +pub static SSH_OVERRIDE: OnceLock = OnceLock::new(); + +/// Writes (once per test run) a script that acts like ssh: it skips the `-o` options, +/// the `--` and the target, then runs the command on this machine. Returns its path. +pub fn fake_ssh() -> PathBuf { + SSH_OVERRIDE + .get_or_init(|| { + let path = std::env::temp_dir().join(format!("gites-fake-ssh-{}", std::process::id())); + let script = "#!/bin/sh\nwhile [ \"$1\" = \"-o\" ]; do shift 2; done\nshift 2\nexec sh -c \"$1\"\n"; + std::fs::write(&path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + }) + .clone() +} -- 2.54.0 From 48e5e8c8ce21d4f8a47aa0b1225de22dd0876ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:11:26 +0200 Subject: [PATCH 06/14] Keep command-line paths that are not valid UTF-8 intact --- src-tauri/src/git.rs | 20 ++++++++++++++++++-- src-tauri/src/lib.rs | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 1df7b49..3c9f4ea 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -194,9 +194,13 @@ pub fn load(loc: &Location) -> Result { /// current folder if it is inside a repository. `None` means gites starts with its /// welcome screen. A path given on the command line is used even if it is not a /// repository, so the window can say what is wrong with it. -pub fn start_dir(arg: Option, cwd: PathBuf) -> Option { +pub fn start_dir(arg: Option, cwd: PathBuf) -> Option { if let Some(arg) = arg { - return Some(crate::location::parse(&arg, &cwd)); + // Text that is not valid UTF-8 cannot be a `host:path`; keep it exactly as given. + return Some(match arg.to_str() { + Some(text) => crate::location::parse(text, &cwd), + None => Location::Local(PathBuf::from(arg)), + }); } let here = Location::Local(cwd); git(&here, &["rev-parse", "--git-dir"]).ok().map(|_| here) @@ -219,6 +223,18 @@ mod tests { assert_eq!(start_dir(Some("/no/such/folder".into()), temp_dir("arg")), Some(Location::Local(given))); } + #[cfg(unix)] + #[test] + fn keeps_a_command_line_path_that_is_not_valid_utf8_intact() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let bytes = b"/tmp/caf\xe9".to_vec(); + let got = start_dir(Some(std::ffi::OsString::from_vec(bytes.clone())), temp_dir("bytes")); + match got { + Some(Location::Local(p)) => assert_eq!(p.as_os_str().as_bytes(), &bytes[..]), + other => panic!("{other:?}"), + } + } + #[test] fn opens_the_current_folder_when_it_is_inside_a_repository() { let dir = temp_dir("repo"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cabac9f..45cf545 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -78,7 +78,7 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let arg = std::env::args_os().nth(1).map(|a| a.to_string_lossy().into_owned()); + let arg = std::env::args_os().nth(1); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let path = git::start_dir(arg, cwd); -- 2.54.0 From 16b0363d07163e0087a6b4857518268e9fd62915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:13:02 +0200 Subject: [PATCH 07/14] Run the stand-in ssh for tests without writing a script file --- src-tauri/src/git.rs | 16 ++++++++------- src-tauri/src/test_ssh.rs | 41 +++++++++++++++++++++------------------ 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 3c9f4ea..47dc064 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,6 +1,6 @@ use serde::Serialize; use crate::location::{Location, remote_command}; -use std::ffi::OsStr; +use std::ffi::OsString; use std::path::PathBuf; use std::process::Command; @@ -53,13 +53,14 @@ pub fn git(loc: &Location, args: &[&str]) -> Result { // Tests swap in a script that acts like ssh. #[cfg(test)] if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.get() { - return git_via(fake.as_os_str(), loc, args); + return git_via(fake, loc, args); } - git_via(OsStr::new("ssh"), loc, args) + git_via(&[OsString::from("ssh")], loc, args) } -/// Does the work of `git`, starting the program `ssh` for a remote location. -pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result { +/// Does the work of `git`. For a remote location it starts `ssh`: the first word of `ssh` is +/// the program and the other words come before the ssh options (tests use them). +pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result { let mut cmd = match loc { Location::Local(dir) => { let mut cmd = Command::new("git"); @@ -67,7 +68,8 @@ pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result { - let mut cmd = Command::new(ssh); + let mut cmd = Command::new(&ssh[0]); + cmd.args(&ssh[1..]); // Never ask for a password: gites has no terminal to ask on. cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]); // Later commands to the same server reuse one connection for a while. Windows ssh @@ -294,7 +296,7 @@ mod tests { #[test] fn reports_a_missing_ssh_program() { let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; - let err = git_via(OsStr::new("/no/such/ssh"), &loc, &["status"]).unwrap_err(); + let err = git_via(&[OsString::from("/no/such/ssh")], &loc, &["status"]).unwrap_err(); assert!(err.starts_with("could not run ssh:"), "{err}"); } } diff --git a/src-tauri/src/test_ssh.rs b/src-tauri/src/test_ssh.rs index 5b433a7..900619a 100644 --- a/src-tauri/src/test_ssh.rs +++ b/src-tauri/src/test_ssh.rs @@ -1,25 +1,28 @@ //! A stand-in for ssh, so tests can run the remote code path without a server. -use std::path::PathBuf; +use std::ffi::OsString; use std::sync::OnceLock; -/// The program `git::git` starts instead of `ssh` while tests run. -pub static SSH_OVERRIDE: OnceLock = OnceLock::new(); +/// The words `git::git` starts instead of `ssh` while tests run: a program, then its arguments. +pub static SSH_OVERRIDE: OnceLock> = OnceLock::new(); -/// Writes (once per test run) a script that acts like ssh: it skips the `-o` options, -/// the `--` and the target, then runs the command on this machine. Returns its path. -pub fn fake_ssh() -> PathBuf { - SSH_OVERRIDE - .get_or_init(|| { - let path = std::env::temp_dir().join(format!("gites-fake-ssh-{}", std::process::id())); - let script = "#!/bin/sh\nwhile [ \"$1\" = \"-o\" ]; do shift 2; done\nshift 2\nexec sh -c \"$1\"\n"; - std::fs::write(&path, script).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - path - }) - .clone() +/// The shell script that acts like ssh. It checks the arguments the way a real ssh would +/// need them (so a wrong argument list fails a test), skips the options and the target, +/// then runs the command on this machine. +const SCRIPT: &str = r#" +ok= +while [ "$1" = "-o" ]; do + [ "$2" = "BatchMode=yes" ] && ok=1 + shift 2 +done +[ -n "$ok" ] || { echo "fake ssh: BatchMode=yes is missing" >&2; exit 98; } +[ "$1" = "--" ] || { echo "fake ssh: expected -- before the target" >&2; exit 99; } +shift 2 +exec sh -c "$1" +"#; + +/// Makes `git::git` use the stand-in for the rest of the test run. No file is written: +/// the script is given to `sh` on its command line. +pub fn fake_ssh() { + SSH_OVERRIDE.get_or_init(|| ["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec()); } -- 2.54.0 From ccf945a067b57d93593eb74e33f434166378168c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:15:41 +0200 Subject: [PATCH 08/14] Open on another machine from the window; keep the graph when a reload fails --- src/App.vue | 4 ++- src/components/RepoMenu.vue | 54 +++++++++++++++++++++++++++++++++---- src/components/Welcome.vue | 36 ++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/App.vue b/src/App.vue index a683db9..937eaca 100644 --- a/src/App.vue +++ b/src/App.vue @@ -116,7 +116,9 @@ async function load() { const data = await invoke("load_repo"); if (data) await applyRepo(data); } catch (e) { - error.value = String(e); + // A repository already on screen stays, for example when a connection drops. + if (repo.value) showToast(`could not reload: ${e}`); + else error.value = String(e); } finally { loading = false; started.value = true; diff --git a/src/components/RepoMenu.vue b/src/components/RepoMenu.vue index d583d03..2567218 100644 --- a/src/components/RepoMenu.vue +++ b/src/components/RepoMenu.vue @@ -1,29 +1,54 @@ @@ -23,7 +43,19 @@ const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p; - +
+ + + +

You can also start gites with a path: gites ~/code/my-project

@@ -44,6 +76,8 @@ h2 { margin: 0 0 4px; font-size: 20px; } .path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; } .item:hover .x, .item:focus-within .x { opacity: 1; } +.actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.remote-input { flex: 1; min-width: 220px; font: 12px ui-monospace, monospace; } .browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } .hint { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; } .hint code { font-family: ui-monospace, monospace; color: var(--text); } -- 2.54.0 From 5ef5c1ba945f71bb86a2d83b21c21f98a990417e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:16:46 +0200 Subject: [PATCH 09/14] README: repositories on another machine --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 8f3b1d8..154386e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,14 @@ In every layout, the words that changed inside a line are marked. - **Opens other repositories** from a list of recent ones, or from a folder picker (o). +- **Opens repositories on another machine.** Write the place as `[user@]host:path`, for + example `gites myserver:/srv/app`, or choose **Open on another machine…** in the + repository menu (o) or on the welcome screen. `host` can be any host name + from your `~/.ssh/config`; that file is also where a port other than 22 goes. gites + runs git on that machine through ssh, so git must be installed there. It logs in with + your ssh keys or the ssh agent and never asks for a password, so a login that needs a + password does not work. You see the history of the repository on that machine: its own + branches and its checked-out branch, as for a repository on this computer. - **Stays current.** gites reloads the repository when its window gets focus again. ![The diff of one file, with old and new side by side and the changed words marked](docs/screenshots/diff.png) @@ -64,6 +72,7 @@ gites runs the `git` command, so git must be installed and on your `PATH`. ```sh gites # the repository in the current folder gites ~/code/my-project # another repository +gites myserver:/srv/app # a repository on another machine, over ssh ``` ## Keys -- 2.54.0 From bf6ae3e2bb3cf9d8509a1a7d86bf4303c5c31753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:26:16 +0200 Subject: [PATCH 10/14] Keep the window responsive and ssh connections safe Commands that run git now run away from the thread that draws the window, so a slow server no longer freezes it. ssh now notices a server that stopped answering in about 10 seconds. The sockets for shared connections go in the runtime folder, or else in a private gites folder in the cache folder, never in /tmp; without either, connections are not shared. A path that is exactly ~ now means the home folder on the server. Tests cover commit details through ssh, the message for a failed connection, and a ~/ path end to end. The stand-in ssh is now set per test, so a test can give it its own home folder. --- src-tauri/src/details.rs | 35 +++++++++++- src-tauri/src/git.rs | 116 ++++++++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 19 ++++--- src-tauri/src/location.rs | 8 ++- src-tauri/src/test_ssh.rs | 24 ++++++-- 5 files changed, 181 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/details.rs b/src-tauri/src/details.rs index 06489d3..c5b8677 100644 --- a/src-tauri/src/details.rs +++ b/src-tauri/src/details.rs @@ -16,7 +16,7 @@ pub struct ChangedFile { pub deleted: Option, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, PartialEq)] pub struct CommitDetails { hash: String, author: String, @@ -153,4 +153,37 @@ mod tests { fn parses_an_empty_diff() { assert_eq!(parse_files("", ""), vec![]); } + + #[test] + fn shows_the_same_details_through_ssh() { + use std::process::Command; + let dir = std::env::temp_dir().join(format!("gites-details-ssh-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let run = |args: &[&str]| { + let out = Command::new("git") + .arg("-C") + .arg(&dir) + .args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"]) + .args(args) + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + run(&["init", "-q", "-b", "main"]); + std::fs::write(dir.join("it's $a; b.txt"), "one\ntwo\n").unwrap(); + run(&["add", "-A"]); + run(&["commit", "-q", "-m", "first"]); + std::fs::write(dir.join("it's $a; b.txt"), "one\nthree\n").unwrap(); + run(&["commit", "-q", "-am", "second\n\nwith a body"]); + let hash = run(&["rev-parse", "HEAD"]); + crate::test_ssh::fake_ssh(); + let remote = Location::Ssh { target: "myserver".into(), path: dir.to_string_lossy().into_owned() }; + let local = load(&Location::Local(dir.clone()), &hash).unwrap(); + let through_ssh = load(&remote, &hash).unwrap(); + assert_eq!(local.files, vec![file("M", "it's $a; b.txt", None, Some(1), Some(1))]); + assert_eq!(local, through_ssh); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 47dc064..6d6813d 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -48,12 +48,39 @@ pub fn ssh_error(target: &str, stderr: &str) -> String { message } +/// The folder for the sockets of shared connections: `runtime`, the user's runtime folder, +/// or else a `gites` folder inside `cache`, the user's cache folder, made if missing so that +/// only the user can open it. `None` when neither gives a folder; then every command opens +/// its own connection. +#[cfg(not(windows))] +fn socket_dir(runtime: Option, cache: Option) -> Option { + if runtime.is_some() { + return runtime; + } + let dir = cache?.join("gites"); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + match builder.create(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {} + Err(_) => return None, + } + // A folder left from before may let others in; only the user may reach the sockets. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok()?; + } + Some(dir) +} + /// Runs git in the folder `loc` names: here, or on another machine through ssh. pub fn git(loc: &Location, args: &[&str]) -> Result { // Tests swap in a script that acts like ssh. #[cfg(test)] - if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.get() { - return git_via(fake, loc, args); + if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.with_borrow(|fake| fake.clone()) { + return git_via(&fake, loc, args); } git_via(&[OsString::from("ssh")], loc, args) } @@ -72,13 +99,15 @@ pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result&2; exit 255", "fake-ssh"].map(OsString::from); + let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; + let err = git_via(&fake, &loc, &["status"]).unwrap_err(); + assert_eq!( + err, + "could not connect to myserver: Host key verification failed.\n\ + Run `ssh myserver` once in a terminal to accept the server's key." + ); + } + + #[test] + fn asks_ssh_to_notice_a_dead_connection() { + // This stand-in prints the words it was given, one per line, and fails. + let fake = ["sh", "-c", "printf '%s\\n' \"$@\" >&2; exit 1", "fake-ssh"].map(OsString::from); + let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; + let err = git_via(&fake, &loc, &["status"]).unwrap_err(); + assert!( + err.contains("-o\nConnectTimeout=10\n-o\nServerAliveInterval=5\n-o\nServerAliveCountMax=2\n"), + "{err}" + ); + } + + #[test] + fn opens_a_path_in_the_home_folder_through_ssh() { + let home = temp_dir("home"); + let dir = home.join("repo"); + std::fs::create_dir_all(&dir).unwrap(); + run_git(&dir, &["init", "-q", "-b", "main"]); + std::fs::write(dir.join("a.txt"), "one\n").unwrap(); + run_git(&dir, &["add", "-A"]); + run_git(&dir, &["commit", "-q", "-m", "first"]); + crate::test_ssh::fake_ssh_with_home(&home); + let data = load(&Location::Ssh { target: "myserver".into(), path: "~/repo".into() }).unwrap(); + assert_eq!(data.commits.len(), 1); + assert_eq!(data.root, format!("myserver:{}", dir.display())); + let _ = std::fs::remove_dir_all(&home); + } + + #[cfg(not(windows))] + #[test] + fn keeps_sockets_in_the_runtime_folder() { + let runtime = PathBuf::from("/run/user/1000"); + assert_eq!(socket_dir(Some(runtime.clone()), Some(PathBuf::from("/c"))), Some(runtime)); + } + + #[cfg(not(windows))] + #[test] + fn keeps_sockets_in_a_private_cache_folder_when_there_is_no_runtime_folder() { + let cache = temp_dir("cache"); + let got = socket_dir(None, Some(cache.clone())); + assert_eq!(got, Some(cache.join("gites"))); + assert!(cache.join("gites").is_dir()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(cache.join("gites")).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + } + // A second call finds the folder already there. + assert_eq!(socket_dir(None, Some(cache.clone())), Some(cache.join("gites"))); + let _ = std::fs::remove_dir_all(&cache); + } + + #[cfg(not(windows))] + #[test] + fn shares_no_connection_when_there_is_no_folder_for_sockets() { + assert_eq!(socket_dir(None, None), None); + // The cache folder is a file here, so no folder can be made inside it. + let base = temp_dir("cache-file"); + let file = base.join("file"); + std::fs::write(&file, "").unwrap(); + assert_eq!(socket_dir(None, Some(file)), None); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn reports_a_missing_ssh_program() { let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45cf545..4365116 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,9 +21,12 @@ impl RepoDir { } } +// The commands that run git are marked `async` so Tauri runs them away from the thread that +// draws the window: a command that waits for a slow server must not freeze the window. + /// The open repository, or `None` when there is none yet. -#[tauri::command] -fn load_repo(dir: tauri::State) -> Result, String> { +#[tauri::command(async)] +fn load_repo(dir: tauri::State<'_, RepoDir>) -> Result, String> { match dir.get() { Ok(d) => git::load(&d).map(Some), Err(_) => Ok(None), @@ -31,8 +34,8 @@ fn load_repo(dir: tauri::State) -> Result, String } /// Switches to another repository. The current one stays open if `path` is not a repository. -#[tauri::command] -fn open_repo(dir: tauri::State, path: String) -> Result { +#[tauri::command(async)] +fn open_repo(dir: tauri::State<'_, RepoDir>, path: String) -> Result { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let loc = location::parse(&path, &cwd); let data = git::load(&loc)?; @@ -40,14 +43,14 @@ fn open_repo(dir: tauri::State, path: String) -> Result, hash: String) -> Result { +#[tauri::command(async)] +fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result { details::load(&dir.get()?, &hash) } -#[tauri::command] +#[tauri::command(async)] fn file_diff( - dir: tauri::State, + dir: tauri::State<'_, RepoDir>, hash: String, path: String, old_path: Option, diff --git a/src-tauri/src/location.rs b/src-tauri/src/location.rs index cf2497d..e0d42c5 100644 --- a/src-tauri/src/location.rs +++ b/src-tauri/src/location.rs @@ -46,11 +46,12 @@ pub fn quote(word: &str) -> String { } /// The command for a shell that reads quotes the standard way: `git -C ` followed by -/// `args`. A path starting with `~/` keeps the `~` outside the quotes so the shell still -/// replaces it with the home folder. +/// `args`. A path that is `~` or starts with `~/` keeps the `~` outside the quotes so the +/// shell still replaces it with the home folder. pub fn sh_command(path: &str, args: &[&str]) -> String { let path = match path.strip_prefix("~/") { Some(rest) => format!("~/{}", quote(rest)), + None if path == "~" => "~".to_string(), None => quote(path), }; let mut command = format!("git -C {path}"); @@ -182,5 +183,8 @@ mod tests { "git -C '/srv/my app' 'log' '--format=%H'" ); assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); + assert_eq!(sh_command("~", &["status"]), "git -C ~ 'status'"); + // Only the home folder of the user logged in is left for the shell; `~other` is not. + assert_eq!(sh_command("~other", &["status"]), "git -C '~other' 'status'"); } } diff --git a/src-tauri/src/test_ssh.rs b/src-tauri/src/test_ssh.rs index 900619a..1a9f2cb 100644 --- a/src-tauri/src/test_ssh.rs +++ b/src-tauri/src/test_ssh.rs @@ -1,10 +1,15 @@ //! A stand-in for ssh, so tests can run the remote code path without a server. +use std::cell::RefCell; use std::ffi::OsString; -use std::sync::OnceLock; +use std::path::Path; -/// The words `git::git` starts instead of `ssh` while tests run: a program, then its arguments. -pub static SSH_OVERRIDE: OnceLock> = OnceLock::new(); +thread_local! { + /// The words `git::git` starts instead of `ssh` in the test running on this thread: a + /// program, then its arguments. Each test runs on its own thread, so one test's stand-in + /// never reaches another test. + pub static SSH_OVERRIDE: RefCell>> = const { RefCell::new(None) }; +} /// The shell script that acts like ssh. It checks the arguments the way a real ssh would /// need them (so a wrong argument list fails a test), skips the options and the target, @@ -21,8 +26,17 @@ shift 2 exec sh -c "$1" "#; -/// Makes `git::git` use the stand-in for the rest of the test run. No file is written: +/// Makes `git::git` use the stand-in for the rest of the calling test. No file is written: /// the script is given to `sh` on its command line. pub fn fake_ssh() { - SSH_OVERRIDE.get_or_init(|| ["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec()); + SSH_OVERRIDE.set(Some(["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec())); +} + +/// Like `fake_ssh`, but the command runs with `home` as its home folder, the way a real +/// server would run it in the user's home. Only the stand-in's own shell sees the new home. +pub fn fake_ssh_with_home(home: &Path) { + let script = format!("HOME=\"$1\"; export HOME; shift\n{SCRIPT}"); + let mut words: Vec = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec(); + words.push(home.as_os_str().to_owned()); + SSH_OVERRIDE.set(Some(words)); } -- 2.54.0 From 7b902616fe07d4b625438b70007b1e60d0285969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:26:16 +0200 Subject: [PATCH 11/14] Let error messages stay until read; show the remote form on the welcome screen Error messages now stay for 8 seconds or until clicked, and keep each hint on its own line. The welcome screen shows how to start gites with a repository on another machine. The spec follows these changes and the new ssh options and socket folder. --- ...-15-remote-repositories-over-ssh-design.md | 19 +++++++++++--- src/App.vue | 26 ++++++++++++++----- src/components/Welcome.vue | 5 +++- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md index c6f5428..e3525f7 100644 --- a/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md +++ b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md @@ -35,7 +35,7 @@ one character, and no local folder with exactly that name exists. Every other te local folder. So `C:\code\x` and `C:/code/x` stay local, `./a:b` stays local, and a folder in the current folder named `notes:2024` stays local when it exists. -A path on the server that starts with `~/` is handed to the server's shell with the +A path on the server that is `~` or starts with `~/` is handed to the server's shell with the `~` outside the quotes, so the shell expands it to the home folder there. ## Running git @@ -49,6 +49,7 @@ A path on the server that starts with `~/` is handed to the server's shell with ``` ssh -o BatchMode=yes -o ConnectTimeout=10 + -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o ControlMaster=auto -o ControlPath= -o ControlPersist=10m user@myserver "git -C '/srv/app' 'log' '--format=…' …" ``` @@ -57,12 +58,18 @@ A path on the server that starts with `~/` is handed to the server's shell with has no terminal to ask in. Keys and the ssh agent work. - `ConnectTimeout=10`: a server that does not answer gives an error after 10 seconds instead of a window that waits for ever. + - `ServerAliveInterval=5`, `ServerAliveCountMax=2`: when a server stops answering + on a connection that is already open (also a shared one), ssh notices it in about + 10 seconds and gives an error instead of waiting for ever. - `ControlMaster`, `ControlPath`, `ControlPersist`: the first command opens one connection and leaves it open; later commands go through it and cost about one network round trip each instead of a new login. The connection closes 10 minutes after the last command. The socket is `gites-%C` in the user's runtime folder - (`/run/user/` on Linux), or in `/tmp` where there is none; `%C` is a hash ssh - makes from the host, port and user, so the path stays short. + (`/run/user/` on Linux). Where there is none (always on macOS), it is in a + `gites` folder in the user's cache folder, made so that only the user can open it; + never in `/tmp`. If neither folder can be found or made, these three options are + left out and every command opens a new connection. `%C` is a hash ssh makes from + the host, port and user, so the path stays short. - On Windows, ssh cannot share connections, so these three options are left out and every command opens a new connection. - Quoting: every word of the git command is put in single quotes, and a `'` inside a @@ -107,6 +114,12 @@ If the connection drops while a repository is open, the next command shows its e as a message at the bottom of the window and the graph that is already on screen stays. The next reload connects again by itself. +A message that reports an error stays for 8 seconds, or until it is clicked, and each +hint line above stays on its own line. Other messages go after 1.8 seconds, as before. + +The commands that run git run away from the thread that draws the window, so the +window does not freeze while it waits for a slow server. + ## Tests - Reading locations: `host:/path`, `user@host:/path`, `host:~/x`, `C:\x`, `C:/x`, a diff --git a/src/App.vue b/src/App.vue index 937eaca..a345287 100644 --- a/src/App.vue +++ b/src/App.vue @@ -70,7 +70,7 @@ watch( if (!settingsLoaded) return; clearTimeout(settingsTimer); settingsTimer = window.setTimeout(() => { - invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`)); + invoke("save_settings", { value: { ...settings } }).catch((e) => showToast(`could not save settings: ${e}`, { error: true })); }, 300); }, { deep: true }, @@ -117,7 +117,7 @@ async function load() { if (data) await applyRepo(data); } catch (e) { // A repository already on screen stays, for example when a connection drops. - if (repo.value) showToast(`could not reload: ${e}`); + if (repo.value) showToast(`could not reload: ${e}`, { error: true }); else error.value = String(e); } finally { loading = false; @@ -131,7 +131,7 @@ async function openRepo(path: string) { await applyRepo(await invoke("open_repo", { path })); } catch (e) { // The repository that was open stays open. - showToast(`could not open ${path}: ${e}`); + showToast(`could not open ${path}: ${e}`, { error: true }); } } @@ -177,7 +177,7 @@ watch( invoke("save_filters", { repo: repoRoot, value: { hidden: filters.hidden, rules: filters.rules, hideTags: filters.hideTags, collapseAll: collapseAll.value }, - }).catch((e) => showToast(`could not save filters: ${e}`)); + }).catch((e) => showToast(`could not save filters: ${e}`, { error: true })); }, 300); }, { deep: true }, @@ -366,10 +366,17 @@ async function copyText(text: string) { // ---- actions ------------------------------------------------------------- let toastTimer: number | undefined; -function showToast(text: string) { +/** Shows a short message. An error message stays longer, 8 seconds, so there is time to + * read it; a click on any message closes it. */ +function showToast(text: string, { error = false } = {}) { toast.value = text; clearTimeout(toastTimer); - toastTimer = window.setTimeout(() => (toast.value = null), 1800); + toastTimer = window.setTimeout(() => (toast.value = null), error ? 8000 : 1800); +} + +function closeToast() { + clearTimeout(toastTimer); + toast.value = null; } async function copyHash() { @@ -653,7 +660,7 @@ onUnmounted(() => { -
{{ toast }}
+
{{ toast }}
@@ -760,6 +767,11 @@ onUnmounted(() => { padding: 6px 14px; border-radius: 8px; box-shadow: 0 6px 24px #0008; + /* Keeps each line of a message on its own line, such as the hint under an ssh error. */ + white-space: pre-line; + max-width: min(640px, calc(100vw - 32px)); + overflow-wrap: anywhere; + cursor: pointer; } .fade-enter-active, .fade-leave-active { transition: opacity 0.2s; } .fade-enter-from, .fade-leave-to { opacity: 0; } diff --git a/src/components/Welcome.vue b/src/components/Welcome.vue index fe4d461..4c25c9e 100644 --- a/src/components/Welcome.vue +++ b/src/components/Welcome.vue @@ -56,7 +56,10 @@ const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p; /> -

You can also start gites with a path: gites ~/code/my-project

+

+ You can also start gites with a path: gites ~/code/my-project, + or with a repository on another machine: gites myserver:/srv/app +

-- 2.54.0 From 25308f5f0f649e3312fc9d2cbd3e7e63219f1e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:31:08 +0200 Subject: [PATCH 12/14] Show only the newest repository when replies arrive out of order Every reload and every open takes a number before asking git; a reply is used only if no newer request was sent after it, and an out-of-date error is not shown. Commit details and diffs that come back after another repository was opened are neither kept nor shown. --- src/App.vue | 35 ++++++++++++++++++++++++++++++++--- src/lib/latest.test.ts | 33 +++++++++++++++++++++++++++++++++ src/lib/latest.ts | 17 +++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/lib/latest.test.ts create mode 100644 src/lib/latest.ts diff --git a/src/App.vue b/src/App.vue index a345287..dc4c8c0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,7 @@ import { layout } from "./lib/layout"; import { textWidth } from "./lib/measure"; import { colorOf } from "./lib/palette"; import { addRecent, mergeRecents } from "./lib/recents"; +import { tickets } from "./lib/latest"; import type { FileDiff, Layout } from "./lib/diff"; import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types"; import DiffView from "./components/DiffView.vue"; @@ -104,6 +105,12 @@ function startDrag(e: PointerEvent, axis: "x" | "y") { let filtersLoadedFor: string | null = null; let loading = false; +/** + * Git runs away from the window, so replies can arrive in another order than the requests + * went out. Every request that can put a new repository on screen (a reload or opening one) + * takes a ticket; a reply is used only if no newer such request was sent after it. + */ +const repoRequests = tickets(); /** False until the first load answers, so the welcome screen does not flash up at start. */ const started = ref(false); @@ -111,11 +118,14 @@ async function load() { // The window gets focus often; one reload at a time is enough. if (loading) return; loading = true; + const ticket = repoRequests.take(); try { // null: gites was started outside a repository and none is open yet. const data = await invoke("load_repo"); - if (data) await applyRepo(data); + if (data) await applyRepo(data, ticket); } catch (e) { + // A newer request replaced this one; its error no longer matters. + if (!repoRequests.isLatest(ticket)) return; // A repository already on screen stays, for example when a connection drops. if (repo.value) showToast(`could not reload: ${e}`, { error: true }); else error.value = String(e); @@ -127,9 +137,12 @@ async function load() { async function openRepo(path: string) { menuOpen.value = false; + const ticket = repoRequests.take(); try { - await applyRepo(await invoke("open_repo", { path })); + await applyRepo(await invoke("open_repo", { path }), ticket); } catch (e) { + // A newer request replaced this one; its error no longer matters. + if (!repoRequests.isLatest(ticket)) return; // The repository that was open stays open. showToast(`could not open ${path}: ${e}`, { error: true }); } @@ -144,12 +157,16 @@ async function browse() { const menuOpen = ref(false); -async function applyRepo(data: RepoData) { +/** Puts `data` on screen, unless a request newer than the one holding `ticket` was sent. */ +async function applyRepo(data: RepoData, ticket: number) { + if (!repoRequests.isLatest(ticket)) return; error.value = null; if (filtersLoadedFor !== data.root) { const saved = await invoke | null>("load_filters", { repo: data.root, }); + // Another repository may have been asked for while the filters were read. + if (!repoRequests.isLatest(ticket)) return; filters.hidden = saved?.hidden ?? []; filters.rules = saved?.rules ?? []; filters.hideTags = saved?.hideTags ?? false; @@ -161,7 +178,11 @@ async function applyRepo(data: RepoData) { diffCache.clear(); if (scroller.value) scroller.value.scrollTop = 0; settings.recents = addRecent(settings.recents, data.root); + // The caches were emptied just above; the new root goes on screen in the same step, so a + // late reply for the old repository sees that the root changed and is not kept. + repo.value = data; await getCurrentWindow().setTitle(`gites — ${data.root}`); + return; } repo.value = data; } @@ -275,14 +296,18 @@ watch([selectedHash, () => settings.previewShown], ([hash, shown]) => { } // Wait a moment, so holding a key down does not run git for every row passed. detailsTimer = window.setTimeout(async () => { + // A reply that comes after another repository was opened belongs to the old one. + const root = repo.value?.root; try { const d = await invoke("commit_details", { hash }); + if (repo.value?.root !== root) return; detailsCache.set(hash, d); if (selectedHash.value === hash) { details.value = d; detailsError.value = null; } } catch (e) { + if (repo.value?.root !== root) return; if (selectedHash.value === hash) detailsError.value = String(e); } }, 120); @@ -308,11 +333,15 @@ async function openDiff(file: ChangedFile, force = false) { return; } diffData.value = null; + // A reply that comes after another repository was opened belongs to the old one. + const root = repo.value?.root; try { const d = await invoke("file_diff", { hash, path: file.path, oldPath: file.old_path, force }); + if (repo.value?.root !== root) return; diffCache.set(key, d); if (diffFile.value === file && details.value?.hash === hash) diffData.value = d; } catch (e) { + if (repo.value?.root !== root) return; if (diffFile.value === file) diffError.value = String(e); } } diff --git a/src/lib/latest.test.ts b/src/lib/latest.test.ts new file mode 100644 index 0000000..41dabdc --- /dev/null +++ b/src/lib/latest.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { tickets } from "./latest"; + +describe("tickets", () => { + it("treats the only ticket taken as the newest", () => { + const t = tickets(); + const a = t.take(); + expect(t.isLatest(a)).toBe(true); + }); + it("treats an older ticket as out of date once a newer one is taken", () => { + const t = tickets(); + const older = t.take(); + const newer = t.take(); + expect(t.isLatest(older)).toBe(false); + expect(t.isLatest(newer)).toBe(true); + }); + it("keeps an older ticket out of date after more tickets are taken", () => { + const t = tickets(); + const first = t.take(); + t.take(); + const last = t.take(); + expect(t.isLatest(first)).toBe(false); + expect(t.isLatest(last)).toBe(true); + }); + it("keeps separate sets of tickets apart", () => { + const a = tickets(); + const b = tickets(); + const fromA = a.take(); + b.take(); + b.take(); + expect(a.isLatest(fromA)).toBe(true); + }); +}); diff --git a/src/lib/latest.ts b/src/lib/latest.ts new file mode 100644 index 0000000..22b1460 --- /dev/null +++ b/src/lib/latest.ts @@ -0,0 +1,17 @@ +/** + * Numbers requests so that only the reply to the newest one is used. Take a ticket before + * sending a request; when its reply arrives, use it only if `isLatest` says no newer ticket + * was taken in the meantime. + */ +export interface Tickets { + take(): number; + isLatest(ticket: number): boolean; +} + +export function tickets(): Tickets { + let newest = 0; + return { + take: () => ++newest, + isLatest: (ticket) => ticket === newest, + }; +} -- 2.54.0 From bd7c129116fd37f0b271c4e7db1e65c60fba6ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:31:08 +0200 Subject: [PATCH 13/14] Share no ssh connection when the socket path would be too long macOS accepts socket paths of at most 103 characters. Count the folder, the file name, the 40-character hash and the temporary end ssh adds; when the total is over 103, leave the connection-sharing options out. Find, make and check the socket folder once per run instead of for every command. --- ...-15-remote-repositories-over-ssh-design.md | 7 +- src-tauri/src/git.rs | 71 +++++++++++++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md index e3525f7..594f077 100644 --- a/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md +++ b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md @@ -69,7 +69,12 @@ A path on the server that is `~` or starts with `~/` is handed to the server's s `gites` folder in the user's cache folder, made so that only the user can open it; never in `/tmp`. If neither folder can be found or made, these three options are left out and every command opens a new connection. `%C` is a hash ssh makes from - the host, port and user, so the path stays short. + the host, port and user, 40 characters long. macOS accepts a socket path of at most + 103 characters (Linux 107), and while ssh sets up a shared connection it adds a + temporary end of up to 18 characters to the file name. So gites counts the folder's + length + 1 + 6 (`gites-`) + 40 + 18; if that is more than 103 (one limit on every + system), these three options are left out too. The folder is found, made and checked + once when gites first runs ssh, not for every command. - On Windows, ssh cannot share connections, so these three options are left out and every command opens a new connection. - Quoting: every word of the git command is put in single quotes, and a `'` inside a diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 6d6813d..8c033f0 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -75,6 +75,33 @@ fn socket_dir(runtime: Option, cache: Option) -> Option bool { + const HASH: usize = 40; + const TEMPORARY_END: usize = 18; + // The 1 is the `/` between the folder and the file name. + dir.as_os_str().len() + 1 + SOCKET_PREFIX.len() + HASH + TEMPORARY_END <= MAX_SOCKET_PATH +} + +/// The path ssh is given for the socket of shared connections, or `None` to share none: when +/// there is no folder for sockets, or the path would be too long. +#[cfg(not(windows))] +fn control_path(dir: Option) -> Option { + dir.filter(|d| socket_path_fits(d)).map(|d| d.join(format!("{SOCKET_PREFIX}%C"))) +} + /// Runs git in the folder `loc` names: here, or on another machine through ssh. pub fn git(loc: &Location, args: &[&str]) -> Result { // Tests swap in a script that acts like ssh. @@ -104,11 +131,17 @@ pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result> = std::sync::OnceLock::new(); + let path = CONTROL_PATH + .get_or_init(|| control_path(socket_dir(dirs::runtime_dir(), dirs::cache_dir()))); + if let Some(path) = path { + cmd.args(["-o", "ControlMaster=auto", "-o"]); + cmd.arg(format!("ControlPath={}", path.display())); + cmd.args(["-o", "ControlPersist=10m"]); + } } // `--` keeps a target from being read as an option. cmd.arg("--").arg(target).arg(remote_command(path, args)); @@ -399,6 +432,36 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[cfg(not(windows))] + #[test] + fn shares_connections_through_a_socket_in_a_short_folder() { + assert!(socket_path_fits(&PathBuf::from("/run/user/1000"))); + assert_eq!( + control_path(Some(PathBuf::from("/run/user/1000"))), + Some(PathBuf::from("/run/user/1000/gites-%C")) + ); + } + + #[cfg(not(windows))] + #[test] + fn shares_no_connection_when_the_socket_path_would_be_too_long() { + // The longest folder that fits: the rest of the path takes 1 + 6 + 40 + 18 characters. + let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18; + let too_long = PathBuf::from(format!("/{}", "a".repeat(longest))); + assert!(!socket_path_fits(&too_long)); + assert_eq!(control_path(Some(too_long)), None); + assert_eq!(control_path(None), None); + } + + #[cfg(not(windows))] + #[test] + fn shares_connections_when_the_socket_path_is_exactly_at_the_limit() { + let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18; + let at_limit = PathBuf::from(format!("/{}", "a".repeat(longest - 1))); + assert_eq!(at_limit.as_os_str().len(), longest); + assert!(socket_path_fits(&at_limit)); + } + #[test] fn reports_a_missing_ssh_program() { let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; -- 2.54.0 From 898135121c0457acb97ba6411fa686a6199424f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 15:32:44 +0200 Subject: [PATCH 14/14] Let opening a repository win over a reload A reload's reply or error is dropped when an open was running as it started, an open started after it, or an open is still running when it answers. An open's reply or error is dropped only when a newer open started after it. --- src/App.vue | 34 ++++++++++--------- src/lib/latest.test.ts | 76 +++++++++++++++++++++++++++--------------- src/lib/latest.ts | 44 ++++++++++++++++++------ 3 files changed, 102 insertions(+), 52 deletions(-) diff --git a/src/App.vue b/src/App.vue index dc4c8c0..8f084cb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,7 +11,7 @@ import { layout } from "./lib/layout"; import { textWidth } from "./lib/measure"; import { colorOf } from "./lib/palette"; import { addRecent, mergeRecents } from "./lib/recents"; -import { tickets } from "./lib/latest"; +import { repoRequests, type Request } from "./lib/latest"; import type { FileDiff, Layout } from "./lib/diff"; import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types"; import DiffView from "./components/DiffView.vue"; @@ -107,10 +107,10 @@ let filtersLoadedFor: string | null = null; let loading = false; /** * Git runs away from the window, so replies can arrive in another order than the requests - * went out. Every request that can put a new repository on screen (a reload or opening one) - * takes a ticket; a reply is used only if no newer such request was sent after it. + * went out. Every reload and every open is noted here, and its reply is used only if + * `requests.isCurrent` allows it: opening a repository wins over a reload. */ -const repoRequests = tickets(); +const requests = repoRequests(); /** False until the first load answers, so the welcome screen does not flash up at start. */ const started = ref(false); @@ -118,14 +118,14 @@ async function load() { // The window gets focus often; one reload at a time is enough. if (loading) return; loading = true; - const ticket = repoRequests.take(); + const request = requests.startReload(); try { // null: gites was started outside a repository and none is open yet. const data = await invoke("load_repo"); - if (data) await applyRepo(data, ticket); + if (data) await applyRepo(data, request); } catch (e) { - // A newer request replaced this one; its error no longer matters. - if (!repoRequests.isLatest(ticket)) return; + // An open replaced this reload; its error no longer matters. + if (!requests.isCurrent(request)) return; // A repository already on screen stays, for example when a connection drops. if (repo.value) showToast(`could not reload: ${e}`, { error: true }); else error.value = String(e); @@ -137,14 +137,16 @@ async function load() { async function openRepo(path: string) { menuOpen.value = false; - const ticket = repoRequests.take(); + const request = requests.startOpen(); try { - await applyRepo(await invoke("open_repo", { path }), ticket); + await applyRepo(await invoke("open_repo", { path }), request); } catch (e) { - // A newer request replaced this one; its error no longer matters. - if (!repoRequests.isLatest(ticket)) return; + // A newer open replaced this one; its error no longer matters. + if (!requests.isCurrent(request)) return; // The repository that was open stays open. showToast(`could not open ${path}: ${e}`, { error: true }); + } finally { + requests.finish(request); } } @@ -157,16 +159,16 @@ async function browse() { const menuOpen = ref(false); -/** Puts `data` on screen, unless a request newer than the one holding `ticket` was sent. */ -async function applyRepo(data: RepoData, ticket: number) { - if (!repoRequests.isLatest(ticket)) return; +/** Puts `data` on screen, unless `request` was replaced (see `requests`). */ +async function applyRepo(data: RepoData, request: Request) { + if (!requests.isCurrent(request)) return; error.value = null; if (filtersLoadedFor !== data.root) { const saved = await invoke | null>("load_filters", { repo: data.root, }); // Another repository may have been asked for while the filters were read. - if (!repoRequests.isLatest(ticket)) return; + if (!requests.isCurrent(request)) return; filters.hidden = saved?.hidden ?? []; filters.rules = saved?.rules ?? []; filters.hideTags = saved?.hideTags ?? false; diff --git a/src/lib/latest.test.ts b/src/lib/latest.test.ts index 41dabdc..f174f0a 100644 --- a/src/lib/latest.test.ts +++ b/src/lib/latest.test.ts @@ -1,33 +1,57 @@ import { describe, expect, it } from "vitest"; -import { tickets } from "./latest"; +import { repoRequests } from "./latest"; -describe("tickets", () => { - it("treats the only ticket taken as the newest", () => { - const t = tickets(); - const a = t.take(); - expect(t.isLatest(a)).toBe(true); +describe("repoRequests", () => { + it("applies a reload with no open around", () => { + const r = repoRequests(); + const reload = r.startReload(); + expect(r.isCurrent(reload)).toBe(true); }); - it("treats an older ticket as out of date once a newer one is taken", () => { - const t = tickets(); - const older = t.take(); - const newer = t.take(); - expect(t.isLatest(older)).toBe(false); - expect(t.isLatest(newer)).toBe(true); + it("applies a reload that starts after an open has finished", () => { + const r = repoRequests(); + const open = r.startOpen(); + r.finish(open); + const reload = r.startReload(); + expect(r.isCurrent(reload)).toBe(true); }); - it("keeps an older ticket out of date after more tickets are taken", () => { - const t = tickets(); - const first = t.take(); - t.take(); - const last = t.take(); - expect(t.isLatest(first)).toBe(false); - expect(t.isLatest(last)).toBe(true); + it("drops a reload started during a running open, and applies the open", () => { + const r = repoRequests(); + const open = r.startOpen(); + const reload = r.startReload(); + // The reload answers first, while the open is still running. + expect(r.isCurrent(reload)).toBe(false); + expect(r.isCurrent(open)).toBe(true); + r.finish(open); + // Its reply may hold the repository from before the open, so it stays dropped. + expect(r.isCurrent(reload)).toBe(false); }); - it("keeps separate sets of tickets apart", () => { - const a = tickets(); - const b = tickets(); - const fromA = a.take(); - b.take(); - b.take(); - expect(a.isLatest(fromA)).toBe(true); + it("drops a reload when an open starts after it", () => { + const r = repoRequests(); + const reload = r.startReload(); + const open = r.startOpen(); + expect(r.isCurrent(reload)).toBe(false); + r.finish(open); + expect(r.isCurrent(reload)).toBe(false); + expect(r.isCurrent(open)).toBe(true); + }); + it("applies only the newer of two opens", () => { + const r = repoRequests(); + const older = r.startOpen(); + const newer = r.startOpen(); + expect(r.isCurrent(older)).toBe(false); + expect(r.isCurrent(newer)).toBe(true); + }); + it("applies an open even when a reload started after it", () => { + const r = repoRequests(); + const open = r.startOpen(); + r.startReload(); + expect(r.isCurrent(open)).toBe(true); + }); + it("keeps separate sets of requests apart", () => { + const a = repoRequests(); + const b = repoRequests(); + const reload = a.startReload(); + b.startOpen(); + expect(a.isCurrent(reload)).toBe(true); }); }); diff --git a/src/lib/latest.ts b/src/lib/latest.ts index 22b1460..788508c 100644 --- a/src/lib/latest.ts +++ b/src/lib/latest.ts @@ -1,17 +1,41 @@ /** - * Numbers requests so that only the reply to the newest one is used. Take a ticket before - * sending a request; when its reply arrives, use it only if `isLatest` says no newer ticket - * was taken in the meantime. + * Decides which replies may put a repository on screen. Git runs away from the window, so + * replies can arrive in another order than the requests went out. Opening a repository wins + * over a reload: + * - a reload's reply is dropped if an open was running when the reload started, if an open + * started after it, or if an open is still running when the reply arrives; + * - an open's reply is dropped only if a newer open started after it. */ -export interface Tickets { - take(): number; - isLatest(ticket: number): boolean; +export type Request = { kind: "open" | "reload"; number: number; duringOpen: boolean }; + +export interface RepoRequests { + startOpen(): Request; + startReload(): Request; + /** Call when an open has finished, whether its reply was used or not. */ + finish(request: Request): void; + isCurrent(request: Request): boolean; } -export function tickets(): Tickets { - let newest = 0; +export function repoRequests(): RepoRequests { + let counter = 0; + let newestOpen = 0; + const runningOpens = new Set(); return { - take: () => ++newest, - isLatest: (ticket) => ticket === newest, + startOpen() { + const number = ++counter; + newestOpen = number; + runningOpens.add(number); + return { kind: "open", number, duringOpen: false }; + }, + startReload() { + return { kind: "reload", number: ++counter, duringOpen: runningOpens.size > 0 }; + }, + finish(request) { + runningOpens.delete(request.number); + }, + isCurrent(request) { + if (request.kind === "open") return request.number === newestOpen; + return !request.duringOpen && newestOpen < request.number && runningOpens.size === 0; + }, }; } -- 2.54.0