Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ libc = "0.2.183"
nix = { version = "0.31.2", features = ["poll"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
shell-quote = { version = "0.7.2", default-features = false, features = ["bash"] }
thiserror = "2"
toml = "0.8"
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,26 +103,27 @@ cargo install --path .
mkdir -p ~/.relocal
echo 'remote = "user@host"' > ~/.relocal/config.toml

# In your project directory, create a per-repo config (optional if user config has remote)
cd ~/my-project
relocal init
# Follow the prompts and set remote to user@host
grep -qxF 'relocal.toml' .gitignore 2>/dev/null || echo 'relocal.toml' >> .gitignore

# Install dependencies on the remote (Rust, Node, Claude Code, Codex, etc.)
cd ~/any-git-repo
relocal remote install

# Start a session (run one or both concurrently)
# Start a session — works from the root of any git repo, no per-repo config needed
relocal claude # Claude Code
relocal codex # Codex
```

Per-repo config is optional. Run `relocal init` in a project to create a `relocal.toml` with repo-specific overrides
(e.g., extra rsync exclusions or a different remote).

Once a session is running, a background loop keeps local in sync with remote changes automatically. Multiple tools can
run against the same session simultaneously — each gets its own interactive SSH session while sharing the sync loop.

Both Claude and Codex authenticate during `relocal remote install`. Codex uses a device code flow (prints a URL and
one-time code to enter in any browser).

Session names default to `<dirname>-<hash>` (e.g., `my-project-a1b2c3d4`), derived from the repo path and git origin.
Use `relocal list` to see session directories on the remote.

### Common Commands

```sh
Expand Down
65 changes: 43 additions & 22 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,33 @@ completely overrides the user-level `exclude`.
Located at `~/.relocal/config.toml`. Created manually by the user. If the file does not exist, it is silently skipped.
If it exists but cannot be parsed, relocal exits with an error.

### Project Config Discovery
### Repo Root Discovery

The repo root is discovered by checking the current working directory for a `relocal.toml` file. Unlike tools that walk
up the directory tree, relocal intentionally only checks the CWD. This prevents accidentally discovering a
`relocal.toml` high in the tree (e.g. in `$HOME`) which would cause the tool to sync an unexpectedly large directory
with `rsync --delete`. If not found, commands that require it fail with an error suggesting `relocal init` or running
from the project root.
The repo root is discovered by checking the current working directory for markers, in order:

1. `relocal.toml` — if present, the directory is the repo root (and project config is loaded).
2. `.git` (directory or file) — if present, the directory is a git repo root.

Unlike tools that walk up the directory tree, relocal intentionally only checks the CWD. This prevents accidentally
syncing an unexpectedly large directory with `rsync --delete`. If neither marker is found, commands that require a repo
root fail with an error suggesting `relocal init` or running from the project root.

Running without a `relocal.toml` requires that the merged config (user config alone, in this case) provides `remote`.

### Pull Safety

Before running `rsync --delete` on a pull, relocal validates that the local destination contains either `relocal.toml`
or `.git`. This is a second line of defense against higher-level bugs passing the wrong path to rsync.

## Session Naming

Each session gets its own remote working copy at `~/relocal/<session-name>/`.

The default session name is the local repo directory name. An explicit name can be passed to commands that accept
`[session-name]`.
The default session name is `<dirname>-<hash>` where `dirname` is the repo root directory name and the hash is derived
from the canonical local path and the git origin URL (SHA-256, truncated to 8 hex chars). This prevents collisions
between different repos that share a directory name, or different checkouts of the same repo.

An explicit name can be passed to commands that accept `[session-name]`.

Session names must contain only alphanumeric characters, hyphens, and underscores. Names containing other characters
(spaces, slashes, dots, etc.) are rejected with an error.
Expand All @@ -84,7 +97,8 @@ concurrency against the same session is prevented by the remote lock file.

## CLI Commands

All commands except `init` require a `relocal.toml` in the current directory.
All commands except `init` require a repo root (a directory containing `relocal.toml` or `.git`). See
[Repo Root Discovery](#repo-root-discovery).

Global flags:

Expand Down Expand Up @@ -149,7 +163,7 @@ Main command. Connects to (or spawns) a session daemon, then launches an interac

Steps:

1. Read `relocal.toml` from the repo root. Fail if not found.
1. Discover the repo root and load merged config (user + project). Fail if `remote` is not set.
2. Validate the session name (see [Session Naming](#session-naming)).
3. Connect to the session daemon for this session name, or spawn one if none is running (see
[Session Daemon](#session-daemon)). The daemon owns the SSH ControlMaster, background sync loop, and remote lock
Expand Down Expand Up @@ -456,8 +470,8 @@ manual cleanup via `relocal destroy`.

### `relocal _daemon`

Hidden internal subcommand. Not intended for direct use. Accepts the session name and repo root path as arguments. Reads
`relocal.toml` from the repo root for the remote host and exclusion patterns.
Hidden internal subcommand. Not intended for direct use. Accepts the session name and repo root path as arguments. Loads
merged config (user + project) from the repo root for the remote host and exclusion patterns.

## Background Sync Loop

Expand Down Expand Up @@ -512,11 +526,11 @@ The polling approach is less efficient than hook-triggered syncs — it runs rsy
`git fsck --strict --full --no-dangling` is run on the remote session directory. If it fails, the pull is refused to
prevent `rsync --delete` from wiping the local tree.
- **Pull safety — local destination validation**: The command runner validates the local pull target before invoking
rsync. It canonicalizes the path and verifies that `relocal.toml` exists there. This is a last line of defense against
a bug in higher-level code passing the wrong `repo_root` to `rsync --delete`. Push does not validate (the destructive
side of `--delete` on push is the remote, not local).
- **Missing `relocal.toml`**: All commands except `init` fail with a clear error message. Only the current working
directory is checked (no upward walk).
rsync. It canonicalizes the path and verifies that `relocal.toml` or `.git` exists there. This is a last line of
defense against a bug in higher-level code passing the wrong `repo_root` to `rsync --delete`. Push does not validate
(the destructive side of `--delete` on push is the remote, not local).
- **No repo root found**: All commands except `init` fail with a clear error message if neither `relocal.toml` nor
`.git` is found in the current directory. Only the current working directory is checked (no upward walk).
- **Remote directory does not exist**: `claude`/`codex` creates it. `ssh` fails (the remote `cd` fails and the user sees
the shell error). `sync` fails (rsync reports the error). `status` reports that the directory does not exist (does not
fail). `destroy` fails with a message that the session was not found.
Expand Down Expand Up @@ -580,14 +594,21 @@ Unit tests cover all pure logic. They do not require SSH, network access, or a r

- Valid names: `my-session`, `session_1`, `foo`, `A-B_C-123`.
- Invalid names: `my session` (space), `a/b` (slash), `a.b` (dot), `../escape` (traversal), empty string.
- Default name derived from directory name (e.g., `/home/user/my-project` → `my-project`).
- Default derivation when directory name contains invalid characters → error with clear message.
- Hashed name format: `<dirname>-<8-hex-chars>`, deterministic for the same path.
- Hashed name differs for different paths or different git origins.
- Hashed name rejects directory names with invalid characters (e.g., dots) rather than sanitizing.
- Hashed name includes git origin URL in hash input (verified by comparing with/without origin).

#### Repo Root Discovery

- `relocal.toml` in current directory → returns current directory.
- `relocal.toml` only in parent (not CWD) → error (does not walk up).
- No `relocal.toml` in CWD → error.
- `.git` directory with `HEAD` file in current directory → returns current directory.
- `.git` file starting with `gitdir:` in current directory (worktree) → returns current directory.
- `.git` directory without `HEAD` → rejected (not a valid git repo).
- `.git` file without `gitdir:` prefix → rejected.
- `relocal.toml` sufficient without `.git`.
- `relocal.toml` or `.git` only in parent (not CWD) → error (does not walk up).
- Neither `relocal.toml` nor `.git` in CWD → error.

#### rsync Argument Construction

Expand All @@ -603,7 +624,7 @@ Unit tests cover all pure logic. They do not require SSH, network access, or a r
- Each subcommand parses correctly with required and optional arguments.
- Verbosity: no flag → WARN, `-v` → INFO, `-vv` → DEBUG, `-vvv` → TRACE.
- Session name present and absent on commands that accept `[session-name]`.
- `init` does not require `relocal.toml`; all other commands do.
- `init` does not require a repo root; all other commands require `relocal.toml` or `.git` in CWD.

### Integration Tests

Expand Down
36 changes: 36 additions & 0 deletions lore/2026-03-22-user-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,39 @@ Two steps, each its own change:
— without it, commands fail as before. But fields like `remote` can come from the user config.
2. Allow running without a project-level `relocal.toml` at all. If the user config provides `remote`, that's enough.
This is the step that actually achieves the "cd into any repo and go" goal.

## Running without a project config (phase 2)

The hard part here isn't config loading — `load_merged_config` already handles a missing project config gracefully. The
hard part is that two safety mechanisms used `relocal.toml` as their anchor, and without it, `rsync --delete` has fewer
guardrails against wiping the wrong directory.

### What relocal.toml was doing for safety

Two things, beyond holding configuration:

1. **Repo root discovery.** `find_repo_root()` only returned a path if `relocal.toml` existed there. This scoped what
rsync would sync — get this wrong and `--delete` wipes whatever directory you pointed it at.
2. **Pull target validation.** Before running `rsync --delete` on a pull, `validate_local_pull_target()` checked that
the local destination contained `relocal.toml`. A second line of defense against a bug in higher-level code passing
the wrong path.

Both of these need replacement anchors when there's no project config.

### .git as the replacement anchor

The `.git` directory (or file, in the case of worktrees) is the natural replacement. If CWD contains `.git`, it's a git
repo root, and that's what relocal syncs. The no-upward-walk rule still applies — same rationale as before about
preventing accidental syncs of parent directories.

For pull validation, accepting `.git` as an alternative to `relocal.toml` is slightly weaker (a `.git` directory is more
common than a `relocal.toml`), but the scenario where rsync targets the wrong git repo is not meaningfully more
dangerous than targeting the right one — the real danger is targeting a non-repo directory like `$HOME`, and `.git`
prevents that.

### Session naming

All default session names are now `<dirname>-<8-hex-chars>`, regardless of whether `relocal.toml` is present. The hash
is SHA-256 of the canonical local path and git origin URL, truncated to 4 bytes. Using the same format everywhere avoids
a class of problems where adding or removing `relocal.toml` silently changes the session name, causing later commands to
miss the existing session and create a duplicate remote working copy.
Loading
Loading