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;hosthas 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 threeControl…options are left out. - Socket:
<runtime folder>/gites-%C, where the runtime folder isdirs::runtime_dir(), or/tmpwhen that isNone. - 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 variableGITES_SSHnames another program (used by tests). - Window text: the menu and welcome entry reads
Open on another machine…; the text field's placeholder isuser@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. sshmissing on this machine: the error must saycould 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:pathtyped 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
-toption 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(addmod location;)
Interfaces:
-
Produces:
#[derive(Clone, Debug, PartialEq)] pub enum Location { Local(PathBuf), Ssh { target: String, path: String } }pub fn parse(text: &str, cwd: &Path) -> Locationimpl 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) -> Stringpub fn remote_command(path: &str, args: &[&str]) -> String:git -C <path> <args…>, every word quoted withquote, except that a path starting with~/is written~/followed by the quoted rest.
-
Step 1: Write the failing tests in
location.rsunder#[cfg(test)] mod tests:reads_remote_locations:parse("myserver:/srv/app", cwd)isSsh { target: "myserver", path: "/srv/app" };parse("ada@myserver:~/x", cwd)isSsh { target: "ada@myserver", path: "~/x" }.keeps_local_paths_local:C:\code\x,C:/code/x,./a:b,relative/dirand/abs/dirall giveLocal(PathBuf::from(text)).keeps_an_existing_local_folder_with_a_colon_local: create<temp>/notes:2024;parse("notes:2024", &temp)isLocal(PathBuf::from("notes:2024")); with that folder removed it isSsh { target: "notes", path: "2024" }.names_locations:Ssh{…}.name()is"myserver:/srv/app".quoted_words_survive_a_shell: for each ofplain,with space,it's,"double",$HOME,a;b,*,line\nbreak:sh -c "printf %s <quote(word)>"prints exactlyword.builds_the_remote_command:remote_command("/srv/my app", &["log", "--format=%H"])isgit -C '/srv/my app' 'log' '--format=%H';remote_command("~/x y", &["status"])isgit -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 runcargo test --manifest-path src-tauri/Cargo.toml location::— Expected: the tests FAIL on their assertions. -
Step 3: Implement the functions in
location.rsfollowing 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.rswith messageRead 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_dirand tests) - Modify:
src-tauri/src/details.rs(load) - Modify:
src-tauri/src/diff.rs(loadand 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 inlib.rswith#[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>: callsgit_viawith the program fromGITES_SSH, orssh.pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result<String, String>: does the work; tests call it with another program without changingGITES_SSH, which other tests running at the same time rely on.pub fn ssh_error(target: &str, stderr: &str) -> String(ingit.rs): the connection message from Global Constraints.pub fn load(loc: &Location) -> Result<RepoData, String>:RepoData.rootisloc.with_path(<toplevel>).name(); later git calls inloaduseloc.with_path(<toplevel>).pub fn start_dir(arg: Option<String>, cwd: PathBuf) -> Option<Location>: an argument goes throughlocation::parse(&arg, &cwd)and is used as is; no argument keeps today's rule (current folder if inside a repository, asLocation::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)parsespathwithlocation::parseagainst the current folder.test_ssh::fake_ssh() -> PathBuf: writes once (guarded bystd::sync::Once) an executableshscript to the temp folder that skips each-o <value>pair and the target, then runsexec sh -c "$1"; setsGITES_SSHto it; returns its path.
-
Step 1: Write the failing tests
- In
git.rs:explains_connection_errors:ssh_error("myserver", "Host key verification failed.\n")containscould not connect to myserver: Host key verification failed.andRun `ssh myserver` once in a terminal; with"ada@myserver: Permission denied (publickey).\n"it containsgites 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 repowith one commit, callfake_ssh(), thenload(&Location::Ssh { target: "myserver".into(), path: "<temp>/remote repo".into() })returns one commit androot == "myserver:<temp>/remote repo". - In
git.rs:reports_a_missing_ssh_program:git_via(OsStr::new("/no/such/ssh"), &Location::Ssh{…}, &["status"])is anErrstarting withcould not run ssh:. - In
diff.rs, modulewith_a_repository:shows_the_same_diff_through_ssh: a repository with a file namedit's $a; b.txtchanged in one commit;loadthroughLocation::Localand throughLocation::Ssh(withfake_ssh()) give equalFileDiffs with at least one hunk. - Update the existing tests to pass
&Location::Local(…)instead of a folder.
- In
-
Step 2: Run
cargo test --manifest-path src-tauri/Cargo.toml— Expected: the new tests FAIL (compile errors are not acceptable: first add stubs forssh_errorand the newgit/loadsignatures). -
Step 3: Implement
git(): local as today (keep the Windows no-console flag on both paths). Remote: program fromGITES_SSHorssh; arguments per Global Constraints; command fromremote_command. A failure to start givescould not run ssh: <error>. Exit code 255 givesssh_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
Locationthroughload,details::load,diff::load,start_dirandlib.rs. run(): pass the first argument as aString(to_string_lossy) tostart_dir.
-
Step 4: Run
cargo test --manifest-path src-tauri/Cargo.tomlandcargo 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_repocommand from Task 2, which now acceptshost:pathtext; App's existingopenRepo(path: string). -
Produces: both components emit the existing
pickevent with the trimmed text typed in the field. -
Step 1: Add the entry. In
RepoMenu.vue, below "Open folder…", an itemOpen on another machine…that, when chosen (click or Enter on it), turns into a text input with placeholderuser@host:/path/to/repo. Enter in the input emitspickwith 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 (noj/k/oactions while typing). InWelcome.vue, the same input next to "Choose a folder…". -
Step 2: Keep the graph when a reload fails. In
App.vue'sload(): when a repository is already on screen (repo.valueis set), show the error withshowToastand keep the graph; only when nothing is open yet, seterror.valueas today. The spec requires this for a connection that drops while a repository is open. -
Step 3: Run
bun run vue-tsc --noEmitandbun 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__.invokewhoseopen_reporecords its argument): typemyserver:/srv/appin each of the two places, press Enter, and confirmopen_reporeceivedmyserver:/srv/app. Confirm Esc in the menu's field closes only the field. Then make the mockedload_repothrow 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 linegites 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 testandmise run check— Expected: PASS. -
Step 4: Commit
README.mdwith messageREADME: repositories on another machine.