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 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`. 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..594f077 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md @@ -0,0 +1,147 @@ +# 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 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 + +`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 ServerAliveInterval=5 -o ServerAliveCountMax=2 + -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. + - `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). 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, 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 + 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. + +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 + 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. diff --git a/src-tauri/src/details.rs b/src-tauri/src/details.rs index acb3862..c5b8677 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 { @@ -16,7 +16,7 @@ pub struct ChangedFile { pub deleted: Option, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, PartialEq)] pub struct CommitDetails { hash: String, author: String, @@ -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")?); @@ -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/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..8c033f0 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::OsString; +use std::path::PathBuf; use std::process::Command; #[derive(Serialize)] @@ -31,10 +33,122 @@ 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 +} + +/// 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) +} + +/// The start of every socket's file name; ssh puts `%C`, a hash of the connection, after it. +#[cfg(not(windows))] +const SOCKET_PREFIX: &str = "gites-"; + +/// The longest socket path macOS accepts (Linux accepts 107). One limit for every system +/// keeps one rule. +#[cfg(not(windows))] +const MAX_SOCKET_PATH: usize = 103; + +/// True when sockets in `dir` stay short enough for the system. ssh puts in place of `%C` a +/// hash of 40 characters, and while it sets up a shared connection it adds a temporary end of +/// up to 18 characters to the file name. +#[cfg(not(windows))] +fn socket_path_fits(dir: &std::path::Path) -> 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. + #[cfg(test)] + 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) +} + +/// 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"); + cmd.arg("-C").arg(dir).args(args); + cmd + } + Location::Ssh { target, path } => { + 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"]); + // If the server stops answering, ssh gives up after about 10 seconds instead of + // waiting for ever, also on a connection that is shared. + cmd.args(["-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]); + // Later commands to the same server reuse one connection for a while. Windows ssh + // does not support this. + // The folder is found, made and checked once per run, not for every command. + #[cfg(not(windows))] + { + static CONTROL_PATH: std::sync::OnceLock> = 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)); + 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 +156,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 +258,16 @@ 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 { + // 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) } #[cfg(test)] @@ -153,15 +284,27 @@ 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))); + } + + #[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"); - 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 +314,158 @@ 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 explains_a_failed_connection_when_ssh_exits_with_255() { + let fake = ["sh", "-c", "echo 'Host key verification failed.' >&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); + } + + #[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() }; + 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/lib.rs b/src-tauri/src/lib.rs index 238c5eb..4365116 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,24 +2,31 @@ mod details; mod diff; 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()) } } +// 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), @@ -27,21 +34,23 @@ 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)); +#[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)?; + *dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(loc); Ok(data) } -#[tauri::command] -fn commit_details(dir: tauri::State, 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, @@ -72,7 +81,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); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let path = git::start_dir(arg, cwd); diff --git a/src-tauri/src/location.rs b/src-tauri/src/location.rs new file mode 100644 index 0000000..e0d42c5 --- /dev/null +++ b/src-tauri/src/location.rs @@ -0,0 +1,190 @@ +//! 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 `\`, 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.starts_with('-') || 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 for a shell that reads quotes the standard way: `git -C ` followed by +/// `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}"); + for arg in args { + command.push(' '); + command.push_str("e(arg)); + } + 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::*; + 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_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())); + 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 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!( + sh_command("/srv/my app", &["log", "--format=%H"]), + "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 new file mode 100644 index 0000000..1a9f2cb --- /dev/null +++ b/src-tauri/src/test_ssh.rs @@ -0,0 +1,42 @@ +//! 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::path::Path; + +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, +/// 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 calling test. No file is written: +/// the script is given to `sh` on its command line. +pub fn fake_ssh() { + 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)); +} diff --git a/src/App.vue b/src/App.vue index a683db9..8f084cb 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 { 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"; @@ -70,7 +71,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 }, @@ -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 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 requests = repoRequests(); /** False until the first load answers, so the welcome screen does not flash up at start. */ const started = ref(false); @@ -111,12 +118,17 @@ async function load() { // The window gets focus often; one reload at a time is enough. if (loading) return; loading = true; + 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); + if (data) await applyRepo(data, request); } catch (e) { - error.value = String(e); + // 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); } finally { loading = false; started.value = true; @@ -125,11 +137,16 @@ async function load() { async function openRepo(path: string) { menuOpen.value = false; + const request = requests.startOpen(); try { - await applyRepo(await invoke("open_repo", { path })); + await applyRepo(await invoke("open_repo", { path }), request); } catch (e) { + // 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}`); + showToast(`could not open ${path}: ${e}`, { error: true }); + } finally { + requests.finish(request); } } @@ -142,12 +159,16 @@ async function browse() { const menuOpen = ref(false); -async function applyRepo(data: RepoData) { +/** 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 (!requests.isCurrent(request)) return; filters.hidden = saved?.hidden ?? []; filters.rules = saved?.rules ?? []; filters.hideTags = saved?.hideTags ?? false; @@ -159,7 +180,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; } @@ -175,7 +200,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 }, @@ -273,14 +298,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); @@ -306,11 +335,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); } } @@ -364,10 +397,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() { @@ -651,7 +691,7 @@ onUnmounted(() => { -
{{ toast }}
+
{{ toast }}
@@ -758,6 +798,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/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,8 +43,23 @@ 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 +

@@ -44,6 +79,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); } diff --git a/src/lib/latest.test.ts b/src/lib/latest.test.ts new file mode 100644 index 0000000..f174f0a --- /dev/null +++ b/src/lib/latest.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { repoRequests } from "./latest"; + +describe("repoRequests", () => { + it("applies a reload with no open around", () => { + const r = repoRequests(); + const reload = r.startReload(); + expect(r.isCurrent(reload)).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("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("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 new file mode 100644 index 0000000..788508c --- /dev/null +++ b/src/lib/latest.ts @@ -0,0 +1,41 @@ +/** + * 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 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 repoRequests(): RepoRequests { + let counter = 0; + let newestOpen = 0; + const runningOpens = new Set(); + return { + 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; + }, + }; +}