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
67 changes: 66 additions & 1 deletion 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 @@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
dialoguer = "0.11"
dirs = "6"
libc = "0.2.183"
nix = { version = "0.31.2", features = ["poll"] }
serde = { version = "1", features = ["derive"] }
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ su - "$NEW_USER" -c 'sudo -n true && echo "passwordless sudo OK"'
# Install relocal locally
cargo install --path .

# In your project directory, create a config file
# Set your default remote (one-time, shared across all repos)
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
Expand Down
32 changes: 25 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,12 @@ scope of this spec.

## Configuration

A single `relocal.toml` file in the repo root. Created by `relocal init`.
Configuration comes from two layers, merged with per-field override semantics:

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.
- **User config**: `~/.relocal/config.toml` — user-wide defaults (e.g., a default remote host).
- **Project config**: `relocal.toml` in the repo root — per-repo overrides. Created by `relocal init`.

Both files use the same schema:

```toml
remote = "user@host"
Expand All @@ -42,7 +41,26 @@ exclude = [".env", "secrets/"]
apt_packages = ["libssl-dev", "pkg-config"]
```

All fields except `remote` are optional.
All fields except `remote` are optional at each layer. The merged result must have `remote`.

### Merge Semantics

For each field, the project config wins if it specifies a value; otherwise the user config's value is used. List fields
(`exclude`, `apt_packages`) are replaced entirely, not concatenated — if a project config specifies `exclude`, it
completely overrides the user-level `exclude`.

### User Config

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

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.

## Session Naming

Expand Down
28 changes: 28 additions & 0 deletions lore/2026-03-22-user-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# User-level configuration

## Problem

I have one or two disposable SSH boxes that I reuse across everything. Right now, every repo I want to use relocal in
needs its own `relocal.toml` with at least `remote = "user@host"`, and I need to make sure it's in `.gitignore`. That's
enough friction that I won't bother for a quick one-off session in a repo I haven't set up yet.

The goal is to be able to `cd` into any git repo and just run `relocal claude` without touching any project files.

## Design

A user-level config at `~/.relocal/config.toml` with the same schema as the project-level `relocal.toml`. The project
file acts as a per-field override when present.

Merge semantics are simple replacement, not union: if a project config specifies `exclude`, that list entirely replaces
the user-level one. I considered concatenation (user excludes + project excludes) but it makes the mental model harder —
you'd need to remember what your user config contributes to figure out what's actually excluded. With replacement, the
project config is "what I want for this repo" and the user config is "my defaults when I haven't said otherwise."

## Phasing

Two steps, each its own change:

1. Add user config loading with project-level override. A `relocal.toml` in the project is still required for discovery
— 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.
8 changes: 7 additions & 1 deletion src/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use std::path::Path;

use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};

use crate::config::Config;
use crate::daemon_client;
Expand Down Expand Up @@ -36,14 +36,20 @@ pub fn run(
verbosity: u8,
extra_args: &[String],
) -> Result<()> {
debug!("Connecting to session daemon for {session_name}...");
let daemon_conn =
daemon_client::connect_or_spawn(session_name, &config.remote, repo_root, verbosity)
.inspect_err(|_| {
info!("Run `relocal log {session_name}` to see daemon logs.");
})?;
debug!(
"Daemon connected, control master at {}",
daemon_conn.control_master_path().display()
);
let runner = ProcessRunner::with_control_path(daemon_conn.control_master_path());

check_tool_installed(tool, &runner, config)?;
debug!("{} installation verified", tool.display_name);

let remote_cmd = (tool.start_session)(session_name, extra_args);
info!("Launching {} on {}...", tool.display_name, config.remote);
Expand Down
Loading
Loading