From ddb05980e0f6396ccfd44545c8f928fcc84b1fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Thu, 15 Oct 2026 14:44:17 +0200 Subject: [PATCH] Plan: open repositories on another machine over ssh --- ...2026-10-15-remote-repositories-over-ssh.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md diff --git a/docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md b/docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md new file mode 100644 index 0000000..aa91e73 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md @@ -0,0 +1,142 @@ +# Repositories on another machine, over ssh: Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Open a git repository on another machine with `gites host:/path` or from the window, and show its history exactly as for a local repository, by running every git command through ssh. + +**Architecture:** A new `Location` value (local folder, or ssh target plus path) replaces the folder that `git()`, `git::load`, `details::load` and `diff::load` take today. `git()` runs git directly for a local location, and `ssh … "git -C '' '' …"` for a remote one, with a shared ssh connection. Nothing above `git()` changes except how the location is chosen at start-up and when opening a repository; the window gets a text field to type `host:/path`. + +**Tech Stack:** Rust (Tauri 2 commands), Vue 3 + TypeScript, OpenSSH client, git. + +**Spec:** `docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md` + +## Global Constraints + +- Remote text form: `[user@]host:path`; `host` has no `/` or `\`, is longer than one character, and no local folder with exactly the whole text as its name exists (relative to the current folder). Everything else is local. +- A remote path starting with `~/` keeps the `~` outside the quotes. +- ssh options, in this order: `-o BatchMode=yes -o ConnectTimeout=10 -o ControlMaster=auto -o ControlPath= -o ControlPersist=10m`, then the target, then one command string. On Windows the three `Control…` options are left out. +- Socket: `/gites-%C`, where the runtime folder is `dirs::runtime_dir()`, or `/tmp` when that is `None`. +- Quoting: each word in single quotes; a `'` inside becomes `'\''`. +- Remote repository name: `:`. +- ssh exit code 255 means a connection error. Messages, exactly: + - `could not connect to : ` + - if stderr contains `Host key verification failed`, add on a new line: ``Run `ssh ` once in a terminal to accept the server's key.`` + - if stderr contains `Permission denied`, add on a new line: ``gites logs in with keys or your ssh agent only; check that `ssh ` works without a password.`` +- The ssh program is `ssh`, unless the environment variable `GITES_SSH` names another program (used by tests). +- Window text: the menu and welcome entry reads `Open on another machine…`; the text field's placeholder is `user@host:/path/to/repo`. +- Commit messages describe the change only: no session links, no co-author lines. + +## Review Focus + +- A remote path with spaces, such as `myserver:/srv/my app`, must open: covered in Task 2's end-to-end test. +- `ssh` missing on this machine: the error must say `could not run ssh: …`, not crash or hang; test in Task 2. +- A file name with `'`, `$`, `;` and a space must give the same diff remotely as locally: Task 2's end-to-end test. +- A `host:path` typed with surrounding spaces in the window's text field must open: the field trims it; test in Task 4 by hand (the window code has no unit-test surface for this component). +- A server whose login shell prints text for non-interactive sessions would corrupt git's output. This cannot be fixed in gites; it is listed so a reviewer checks that nothing in the code makes it worse (for example, no `-t` option is passed to ssh). + +--- + +### Task 1: Location: reading `host:path`, quoting, the remote command + +**Files:** +- Create: `src-tauri/src/location.rs` +- Modify: `src-tauri/src/lib.rs` (add `mod location;`) + +**Interfaces:** +- Produces: + - `#[derive(Clone, Debug, PartialEq)] pub enum Location { Local(PathBuf), Ssh { target: String, path: String } }` + - `pub fn parse(text: &str, cwd: &Path) -> Location` + - `impl Location { pub fn name(&self) -> String }`: local → the path as text (`to_string_lossy`); remote → `"{target}:{path}"`. + - `impl Location { pub fn with_path(&self, path: &str) -> Location }`: same kind and target, another path (`Local(PathBuf::from(path))` for local). + - `pub fn quote(word: &str) -> String` + - `pub fn remote_command(path: &str, args: &[&str]) -> String`: `git -C `, every word quoted with `quote`, except that a path starting with `~/` is written `~/` followed by the quoted rest. + +- [ ] **Step 1: Write the failing tests** in `location.rs` under `#[cfg(test)] mod tests`: + - `reads_remote_locations`: `parse("myserver:/srv/app", cwd)` is `Ssh { target: "myserver", path: "/srv/app" }`; `parse("ada@myserver:~/x", cwd)` is `Ssh { target: "ada@myserver", path: "~/x" }`. + - `keeps_local_paths_local`: `C:\code\x`, `C:/code/x`, `./a:b`, `relative/dir` and `/abs/dir` all give `Local(PathBuf::from(text))`. + - `keeps_an_existing_local_folder_with_a_colon_local`: create `/notes:2024`; `parse("notes:2024", &temp)` is `Local(PathBuf::from("notes:2024"))`; with that folder removed it is `Ssh { target: "notes", path: "2024" }`. + - `names_locations`: `Ssh{…}.name()` is `"myserver:/srv/app"`. + - `quoted_words_survive_a_shell`: for each of `plain`, `with space`, `it's`, `"double"`, `$HOME`, `a;b`, `*`, `line\nbreak`: `sh -c "printf %s "` prints exactly `word`. + - `builds_the_remote_command`: `remote_command("/srv/my app", &["log", "--format=%H"])` is `git -C '/srv/my app' 'log' '--format=%H'`; `remote_command("~/x y", &["status"])` is `git -C ~/'x y' 'status'`. + +- [ ] **Step 2: Add stubs** for every function in Interfaces (returning `Location::Local(PathBuf::new())` / `String::new()`) so the tests compile, then run `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: the tests FAIL on their assertions. + +- [ ] **Step 3: Implement** the functions in `location.rs` following Global Constraints. + +- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml location::` — Expected: PASS. + +- [ ] **Step 5: Commit** `src-tauri/src/location.rs src-tauri/src/lib.rs` with message `Read host:path locations and quote words for a server's shell`. + +### Task 2: Run git through ssh, and pass locations everywhere + +**Files:** +- Modify: `src-tauri/src/git.rs` (`git`, `load`, `start_dir` and tests) +- Modify: `src-tauri/src/details.rs` (`load`) +- Modify: `src-tauri/src/diff.rs` (`load` and tests) +- Modify: `src-tauri/src/lib.rs` (`RepoDir`, every command that reads it, `open_repo`, `run`) +- Create: `src-tauri/src/test_ssh.rs` (`#[cfg(test)]` only; registered in `lib.rs` with `#[cfg(test)] mod test_ssh;`) + +**Interfaces:** +- Consumes: Task 1's `Location`, `parse`, `name`, `with_path`, `remote_command`. +- Produces: + - `pub fn git(loc: &Location, args: &[&str]) -> Result`: calls `git_via` with the program from `GITES_SSH`, or `ssh`. + - `pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result`: does the work; tests call it with another program without changing `GITES_SSH`, which other tests running at the same time rely on. + - `pub fn ssh_error(target: &str, stderr: &str) -> String` (in `git.rs`): the connection message from Global Constraints. + - `pub fn load(loc: &Location) -> Result`: `RepoData.root` is `loc.with_path().name()`; later git calls in `load` use `loc.with_path()`. + - `pub fn start_dir(arg: Option, cwd: PathBuf) -> Option`: an argument goes through `location::parse(&arg, &cwd)` and is used as is; no argument keeps today's rule (current folder if inside a repository, as `Location::Local`). + - `details::load(loc: &Location, hash: &str)`, `diff::load(loc: &Location, hash, path, old_path, force)`: same behaviour, location instead of folder. + - `struct RepoDir(Mutex>)`; `open_repo(path: String)` parses `path` with `location::parse` against the current folder. + - `test_ssh::fake_ssh() -> PathBuf`: writes once (guarded by `std::sync::Once`) an executable `sh` script to the temp folder that skips each `-o ` pair and the target, then runs `exec sh -c "$1"`; sets `GITES_SSH` to it; returns its path. + +- [ ] **Step 1: Write the failing tests** + - In `git.rs`: `explains_connection_errors`: `ssh_error("myserver", "Host key verification failed.\n")` contains `could not connect to myserver: Host key verification failed.` and ``Run `ssh myserver` once in a terminal``; with `"ada@myserver: Permission denied (publickey).\n"` it contains `gites logs in with keys or your ssh agent only`; with `"ssh: connect to host myserver port 22: Connection timed out"` it contains only the first line. + - In `git.rs`: `loads_a_repository_through_ssh`: make a repository at `/remote repo` with one commit, call `fake_ssh()`, then `load(&Location::Ssh { target: "myserver".into(), path: "/remote repo".into() })` returns one commit and `root == "myserver:/remote repo"`. + - In `git.rs`: `reports_a_missing_ssh_program`: `git_via(OsStr::new("/no/such/ssh"), &Location::Ssh{…}, &["status"])` is an `Err` starting with `could not run ssh:`. + - In `diff.rs`, module `with_a_repository`: `shows_the_same_diff_through_ssh`: a repository with a file named `it's $a; b.txt` changed in one commit; `load` through `Location::Local` and through `Location::Ssh` (with `fake_ssh()`) give equal `FileDiff`s with at least one hunk. + - Update the existing tests to pass `&Location::Local(…)` instead of a folder. + +- [ ] **Step 2: Run** `cargo test --manifest-path src-tauri/Cargo.toml` — Expected: the new tests FAIL (compile errors are not acceptable: first add stubs for `ssh_error` and the new `git`/`load` signatures). + +- [ ] **Step 3: Implement** + - `git()`: local as today (keep the Windows no-console flag on both paths). Remote: program from `GITES_SSH` or `ssh`; arguments per Global Constraints; command from `remote_command`. A failure to start gives `could not run ssh: `. Exit code 255 gives `ssh_error(target, stderr)`; other failures give the trimmed stderr, as today. + - Socket path: `dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C")`. + - Thread the `Location` through `load`, `details::load`, `diff::load`, `start_dir` and `lib.rs`. + - `run()`: pass the first argument as a `String` (`to_string_lossy`) to `start_dir`. + +- [ ] **Step 4: Run** `cargo test --manifest-path src-tauri/Cargo.toml` and `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets` — Expected: all tests PASS; clippy shows no new warnings. + +- [ ] **Step 5: Commit** the files above with message `Run git through ssh for repositories on another machine`. + +### Task 3: Open a remote repository from the window + +**Files:** +- Modify: `src/components/RepoMenu.vue` +- Modify: `src/components/Welcome.vue` +- Modify: `src/App.vue` (`load`) + +**Interfaces:** +- Consumes: the `open_repo` command from Task 2, which now accepts `host:path` text; App's existing `openRepo(path: string)`. +- Produces: both components emit the existing `pick` event with the trimmed text typed in the field. + +- [ ] **Step 1: Add the entry.** In `RepoMenu.vue`, below "Open folder…", an item `Open on another machine…` that, when chosen (click or Enter on it), turns into a text input with placeholder `user@host:/path/to/repo`. Enter in the input emits `pick` with the trimmed text when it is not empty; Esc turns it back into the item. Keys typed in the input must not reach the menu's own key handler (no `j`/`k`/`o` actions while typing). In `Welcome.vue`, the same input next to "Choose a folder…". + +- [ ] **Step 2: Keep the graph when a reload fails.** In `App.vue`'s `load()`: when a repository is already on screen (`repo.value` is set), show the error with `showToast` and keep the graph; only when nothing is open yet, set `error.value` as today. The spec requires this for a connection that drops while a repository is open. + +- [ ] **Step 3: Run** `bun run vue-tsc --noEmit` and `bun run vitest run` — Expected: no type errors, all tests PASS. + +- [ ] **Step 4: Check in the browser** with the stand-in backend used for the earlier features (Vite dev server plus a mocked `__TAURI_INTERNALS__.invoke` whose `open_repo` records its argument): type ` myserver:/srv/app ` in each of the two places, press Enter, and confirm `open_repo` received `myserver:/srv/app`. Confirm Esc in the menu's field closes only the field. Then make the mocked `load_repo` throw and trigger a reload (focus the window): the graph stays and a message appears at the bottom. + +- [ ] **Step 5: Commit** with message `Open on another machine from the window; keep the graph when a reload fails`. + +### Task 4: README and a check against a real server + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: README.** In "What it does", a bullet: repositories on another machine open with `gites myserver:/srv/app`, or from the repository menu; git runs there through ssh, which must log in without a password. In "Use", the line `gites myserver:/srv/app # a repository on another machine, over ssh`. + +- [ ] **Step 2: Real server, by hand.** Build with `mise run dev :` against a server the user names. Check: the graph loads; a commit's details; a file's diff; reload with r; a wrong path shows git's "not a git repository" message; an unknown host shows the connection message. Report what was checked and what was not. + +- [ ] **Step 3: Run** `mise run test` and `mise run check` — Expected: PASS. + +- [ ] **Step 4: Commit** `README.md` with message `README: repositories on another machine`.