Files
gites/docs/superpowers/plans/2026-10-15-remote-repositories-over-ssh.md

13 KiB

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 FileDiffs 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 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 <host>:<path> 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.