Open repositories on another machine over ssh; keep the window responsive while git runs #5

Open
szaman wants to merge 14 commits from feature/remote-ssh into master
14 changed files with 1178 additions and 66 deletions
+9
View File
@@ -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
(<kbd>o</kbd>).
- **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 (<kbd>o</kbd>) 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
@@ -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 '<path>' '<arg>' …"` 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=<socket> -o ControlPersist=10m`, then the target, then one command string. On Windows the three `Control…` options are left out.
- Socket: `<runtime folder>/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: `<target>:<output of git rev-parse --show-toplevel on the server>`.
- ssh exit code 255 means a connection error. Messages, exactly:
- `could not connect to <target>: <ssh stderr, trimmed>`
- if stderr contains `Host key verification failed`, add on a new line: ``Run `ssh <target>` 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 <target>` 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 <path> <args…>`, 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 `<temp>/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 <quote(word)>"` 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<String, String>`: calls `git_via` with the program from `GITES_SSH`, or `ssh`.
- `pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result<String, String>`: 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, String>`: `RepoData.root` is `loc.with_path(<toplevel>).name()`; later git calls in `load` use `loc.with_path(<toplevel>)`.
- `pub fn start_dir(arg: Option<String>, cwd: PathBuf) -> Option<Location>`: 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<Option<Location>>)`; `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 <value>` 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 `<temp>/remote repo` with one commit, call `fake_ssh()`, then `load(&Location::Ssh { target: "myserver".into(), path: "<temp>/remote repo".into() })` returns one commit and `root == "myserver:<temp>/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: <error>`. 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 <kbd>Enter</kbd> on it), turns into a text input with placeholder `user@host:/path/to/repo`. <kbd>Enter</kbd> in the input emits `pick` with the trimmed text when it is not empty; <kbd>Esc</kbd> 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 <kbd>Enter</kbd>, and confirm `open_repo` received `myserver:/srv/app`. Confirm <kbd>Esc</kbd> 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 <host>:<path>` against a server the user names. Check: the graph loads; a commit's details; a file's diff; reload with <kbd>r</kbd>; 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`.
@@ -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 <folder> <args>`, 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=<socket> -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/<id>` 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 `<target>:<top folder>`, 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.
+38 -5
View File
@@ -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<u32>,
}
#[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<ChangedFile> {
files
}
pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> {
pub fn load(loc: &Location, hash: &str) -> Result<CommitDetails, String> {
// 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<CommitDetails, String> {
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);
}
}
+34 -15
View File
@@ -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<FileDiff, String> {
pub fn load(loc: &Location, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result<FileDiff, String> {
// 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);
}
}
}
+312 -15
View File
@@ -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<String>,
}
pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
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<PathBuf>, cache: Option<PathBuf>) -> Option<PathBuf> {
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<PathBuf>) -> Option<PathBuf> {
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<String, String> {
// 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<String, String> {
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<Option<PathBuf>> = 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<String, String> {
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<RepoData, String> {
let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string();
let root_path = Path::new(&root);
pub fn load(loc: &Location) -> Result<RepoData, String> {
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<RepoData, String> {
/// 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<PathBuf>, cwd: PathBuf) -> Option<PathBuf> {
arg.or_else(|| git(&cwd, &["rev-parse", "--git-dir"]).ok().map(|_| cwd))
pub fn start_dir(arg: Option<std::ffi::OsString>, cwd: PathBuf) -> Option<Location> {
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}");
}
}
+23 -14
View File
@@ -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<Option<PathBuf>>);
struct RepoDir(Mutex<Option<Location>>);
impl RepoDir {
fn get(&self) -> Result<PathBuf, String> {
fn get(&self) -> Result<Location, String> {
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<RepoDir>) -> Result<Option<git::RepoData>, String> {
#[tauri::command(async)]
fn load_repo(dir: tauri::State<'_, RepoDir>) -> Result<Option<git::RepoData>, String> {
match dir.get() {
Ok(d) => git::load(&d).map(Some),
Err(_) => Ok(None),
@@ -27,21 +34,23 @@ fn load_repo(dir: tauri::State<RepoDir>) -> Result<Option<git::RepoData>, String
}
/// Switches to another repository. The current one stays open if `path` is not a repository.
#[tauri::command]
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
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<git::RepoData, String> {
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<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
#[tauri::command(async)]
fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
details::load(&dir.get()?, &hash)
}
#[tauri::command]
#[tauri::command(async)]
fn file_diff(
dir: tauri::State<RepoDir>,
dir: tauri::State<'_, RepoDir>,
hash: String,
path: String,
old_path: Option<String>,
@@ -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);
+190
View File
@@ -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 <path>` 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(&quote(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 <path> <args>` 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'");
}
}
+42
View File
@@ -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<Option<Vec<OsString>>> = 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<OsString> = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec();
words.push(home.as_os_str().to_owned());
SSH_OVERRIDE.set(Some(words));
}
+55 -10
View File
@@ -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<RepoData | null>("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<RepoData>("open_repo", { path }));
await applyRepo(await invoke<RepoData>("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<Partial<FilterState & { collapseAll: boolean }> | 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<CommitDetails>("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<FileDiff>("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(() => {
</footer>
<transition name="fade">
<div v-if="toast" class="toast">{{ toast }}</div>
<div v-if="toast" class="toast" @click="closeToast">{{ toast }}</div>
</transition>
</div>
</template>
@@ -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; }
+49 -5
View File
@@ -1,29 +1,54 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, nextTick, onMounted, ref } from "vue";
const props = defineProps<{ recents: string[]; current: string | null }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
// The current repository is not offered: picking it would do nothing.
const items = computed(() => props.recents.filter((p) => p !== props.current));
/** Index into items; items.length means the "Open folder…" entry. */
/** Index into items; items.length is the "Open folder…" entry and items.length + 1 the "Open on another machine…" entry. */
const active = ref(0);
const menu = ref<HTMLElement>();
onMounted(() => menu.value?.focus());
/** True while the "Open on another machine…" entry is a text field. */
const typing = ref(false);
const remoteText = ref("");
const remoteInput = ref<HTMLInputElement>();
async function startTyping() {
typing.value = true;
await nextTick();
remoteInput.value?.focus();
}
function choose(i: number) {
if (i >= items.value.length) emit("browse");
if (i === items.value.length + 1) void startTyping();
else if (i === items.value.length) emit("browse");
else emit("pick", items.value[i]);
}
function onRemoteKey(e: KeyboardEvent) {
// Typed letters belong to the field, not to the menu's own keys.
e.stopPropagation();
if (e.key === "Enter") {
const text = remoteText.value.trim();
if (text) emit("pick", text);
} else if (e.key === "Escape") {
typing.value = false;
menu.value?.focus();
} else return;
e.preventDefault();
}
function onKey(e: KeyboardEvent) {
// Keys stay in the menu while it is open.
e.stopPropagation();
const last = items.value.length;
const last = items.value.length + 1;
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
else if (e.key === "Enter") choose(active.value);
else if (e.key === "Delete" && active.value < last) emit("remove", items.value[active.value]);
else if (e.key === "Delete" && active.value < items.value.length) emit("remove", items.value[active.value]);
else if (e.key === "Escape" || e.key === "o") emit("close");
else return;
e.preventDefault();
@@ -57,6 +82,23 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
>
Open folder…
</div>
<div
class="item remote"
:class="{ active: active === items.length + 1 }"
@mouseenter="active = items.length + 1"
@click="!typing && choose(items.length + 1)"
>
<input
v-if="typing"
ref="remoteInput"
v-model="remoteText"
class="remote-input"
placeholder="user@host:/path/to/repo"
spellcheck="false"
@keydown="onRemoteKey"
/>
<template v-else>Open on another machine…</template>
</div>
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
</div>
</template>
@@ -87,5 +129,7 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
.item.active .x { opacity: 1; }
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
.remote { color: var(--accent); }
.remote-input { flex: 1; min-width: 0; font: 12px ui-monospace, monospace; }
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
</style>
+39 -2
View File
@@ -1,7 +1,27 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
defineProps<{ recents: string[] }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
/** True while the "Open on another machine…" button is a text field. */
const typing = ref(false);
const remoteText = ref("");
const remoteInput = ref<HTMLInputElement>();
async function startTyping() {
typing.value = true;
await nextTick();
remoteInput.value?.focus();
}
function onRemoteKey(e: KeyboardEvent) {
if (e.key === "Enter") {
const text = remoteText.value.trim();
if (text) emit("pick", text);
} else if (e.key === "Escape") typing.value = false;
}
// Paths on Windows use backslashes.
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
</script>
@@ -23,8 +43,23 @@ const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
</div>
</div>
</template>
<button class="browse" @click="emit('browse')">Choose a folder…</button>
<p class="hint">You can also start gites with a path: <code>gites ~/code/my-project</code></p>
<div class="actions">
<button class="browse" @click="emit('browse')">Choose a folder…</button>
<input
v-if="typing"
ref="remoteInput"
v-model="remoteText"
class="remote-input"
placeholder="user@host:/path/to/repo"
spellcheck="false"
@keydown="onRemoteKey"
/>
<button v-else class="browse" @click="startTyping">Open on another machine…</button>
</div>
<p class="hint">
You can also start gites with a path: <code>gites ~/code/my-project</code>,
or with a repository on another machine: <code>gites myserver:/srv/app</code>
</p>
</div>
</div>
</template>
@@ -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); }
+57
View File
@@ -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);
});
});
+41
View File
@@ -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<number>();
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;
},
};
}