Open repositories on another machine over ssh; keep the window responsive while git runs #5
@@ -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.
|
||||
|
||||

|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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("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 <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'");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user