Plan: open repositories on another machine over ssh
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user