From 337a5a7fc4b2bb69eac89bb5b8ba314abe2fe1ba Mon Sep 17 00:00:00 2001 From: Peter Schuller Date: Sun, 22 Mar 2026 08:45:58 -0700 Subject: [PATCH] feat: run in any git repo without a project config file --- Cargo.lock | 72 +++++++++++++ Cargo.toml | 1 + README.md | 15 +-- SPEC.md | 65 ++++++++---- lore/2026-03-22-user-config.md | 36 +++++++ src/cli.rs | 16 +-- src/discovery.rs | 133 +++++++++++++++++++++--- src/error.rs | 2 +- src/main.rs | 6 +- src/rsync.rs | 2 +- src/runner.rs | 63 ++++++++---- src/session.rs | 183 +++++++++++++++++++++++++++++---- tests/integration.rs | 87 ++++++++++++++++ 13 files changed, 586 insertions(+), 95 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99bb0ae..2782d9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -135,6 +144,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -148,6 +176,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -203,6 +241,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -424,6 +472,7 @@ dependencies = [ "nix", "serde", "serde_json", + "sha2", "shell-quote", "tempfile", "thiserror 2.0.18", @@ -503,6 +552,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -707,6 +767,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -737,6 +803,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index eee44e2..d356e6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index c7daa24..afa2013 100644 --- a/README.md +++ b/README.md @@ -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 `-` (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 diff --git a/SPEC.md b/SPEC.md index e196fc4..8905360 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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//`. -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 `-` 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. @@ -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: @@ -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 @@ -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 @@ -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. @@ -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: `-<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 @@ -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 diff --git a/lore/2026-03-22-user-config.md b/lore/2026-03-22-user-config.md index 5e5ab9a..aa4b894 100644 --- a/lore/2026-03-22-user-config.md +++ b/lore/2026-03-22-user-config.md @@ -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 `-<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. diff --git a/src/cli.rs b/src/cli.rs index 162269c..92e5c12 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -41,7 +41,7 @@ pub enum Command { /// Sync and launch an interactive Claude session on the remote. Claude { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, /// Extra arguments passed through to `claude` (after `--`). @@ -51,7 +51,7 @@ pub enum Command { /// Sync and launch an interactive Codex session on the remote. Codex { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, /// Extra arguments passed through to `codex` (after `--`). @@ -61,7 +61,7 @@ pub enum Command { /// Open an interactive SSH shell in the remote session directory. Ssh { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, }, @@ -73,13 +73,13 @@ pub enum Command { /// Show session status. Status { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, }, /// Tail the daemon log for a session. Log { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, }, @@ -88,7 +88,7 @@ pub enum Command { /// Remove a session's remote working copy. Destroy { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, }, @@ -114,12 +114,12 @@ pub enum RemoteCommand { pub enum SyncCommand { /// Push local files to the remote. Push { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, }, /// Pull remote files to local. Pull { - /// Session name (defaults to directory name). + /// Session name (defaults to -). session_name: Option, }, } diff --git a/src/discovery.rs b/src/discovery.rs index 597ec44..eb0b6f9 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,21 +1,37 @@ -//! Repo root discovery — checks only the current directory for `relocal.toml`. +//! Repo root discovery — checks the current directory for `relocal.toml` or `.git`. //! //! Unlike tools that walk up the directory tree (git, cargo), relocal intentionally -//! only checks the given directory. This prevents accidentally discovering a -//! `relocal.toml` high in the tree and syncing an unexpectedly large directory. +//! only checks the given directory. This prevents accidentally syncing an +//! unexpectedly large directory with `rsync --delete`. use std::path::{Path, PathBuf}; use crate::error::{Error, Result}; -/// Checks whether `start` contains a `relocal.toml` file. +/// Checks whether `dir` contains a valid `.git` marker. /// -/// Unlike tools that walk up the directory tree, this intentionally only -/// checks the given directory. This prevents accidentally discovering a -/// `relocal.toml` high up the tree (e.g. in `$HOME`) which would cause -/// the tool to sync an unexpectedly large directory with `--delete`. +/// Accepts either a `.git` directory containing a `HEAD` file (normal repo) +/// or a `.git` file starting with `gitdir:` (worktree). Rejects stray files +/// or directories named `.git` that aren't actual git repos. +pub fn is_git_root(dir: &Path) -> bool { + let git_path = dir.join(".git"); + if git_path.is_dir() { + return git_path.join("HEAD").is_file(); + } + if git_path.is_file() { + return std::fs::read_to_string(&git_path) + .map(|s| s.starts_with("gitdir:")) + .unwrap_or(false); + } + false +} + +/// Finds the repo root by checking `start` for known markers. +/// +/// Checks for `relocal.toml` first, then a valid `.git` marker. Does NOT +/// walk up the directory tree — only checks the given directory. pub fn find_repo_root(start: &Path) -> Result { - if start.join("relocal.toml").is_file() { + if start.join("relocal.toml").is_file() || is_git_root(start) { return Ok(start.to_path_buf()); } Err(Error::ConfigNotFound { @@ -30,12 +46,57 @@ mod tests { use tempfile::TempDir; #[test] - fn found_in_current_dir() { + fn found_via_relocal_toml() { let tmp = TempDir::new().unwrap(); fs::write(tmp.path().join("relocal.toml"), "remote = \"u@h\"").unwrap(); assert_eq!(find_repo_root(tmp.path()).unwrap(), tmp.path()); } + #[test] + fn found_via_git_dir_with_head() { + let tmp = TempDir::new().unwrap(); + let git_dir = tmp.path().join(".git"); + fs::create_dir(&git_dir).unwrap(); + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + assert_eq!(find_repo_root(tmp.path()).unwrap(), tmp.path()); + } + + #[test] + fn found_via_git_worktree_file() { + let tmp = TempDir::new().unwrap(); + fs::write( + tmp.path().join(".git"), + "gitdir: /some/path/.git/worktrees/foo", + ) + .unwrap(); + assert_eq!(find_repo_root(tmp.path()).unwrap(), tmp.path()); + } + + #[test] + fn rejects_stray_git_dir_without_head() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join(".git")).unwrap(); + // No HEAD file — not a real git repo + let err = find_repo_root(tmp.path()).unwrap_err(); + assert!(matches!(err, Error::ConfigNotFound { .. })); + } + + #[test] + fn rejects_stray_git_file_without_gitdir() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join(".git"), "not a worktree").unwrap(); + let err = find_repo_root(tmp.path()).unwrap_err(); + assert!(matches!(err, Error::ConfigNotFound { .. })); + } + + #[test] + fn relocal_toml_sufficient_without_git() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("relocal.toml"), "remote = \"u@h\"").unwrap(); + // No .git at all — still found via relocal.toml + assert_eq!(find_repo_root(tmp.path()).unwrap(), tmp.path()); + } + #[test] fn not_found() { let tmp = TempDir::new().unwrap(); @@ -44,14 +105,62 @@ mod tests { } #[test] - fn does_not_walk_up_to_parent() { + fn does_not_walk_up_to_parent_relocal_toml() { let tmp = TempDir::new().unwrap(); fs::write(tmp.path().join("relocal.toml"), "remote = \"u@h\"").unwrap(); let child = tmp.path().join("subdir"); fs::create_dir(&child).unwrap(); + let err = find_repo_root(&child).unwrap_err(); + assert!(matches!(err, Error::ConfigNotFound { .. })); + } - // Must NOT find relocal.toml in parent — only checks the given directory + #[test] + fn does_not_walk_up_to_parent_git() { + let tmp = TempDir::new().unwrap(); + let git_dir = tmp.path().join(".git"); + fs::create_dir(&git_dir).unwrap(); + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let child = tmp.path().join("subdir"); + fs::create_dir(&child).unwrap(); let err = find_repo_root(&child).unwrap_err(); assert!(matches!(err, Error::ConfigNotFound { .. })); } + + // --- is_git_root tests --- + + #[test] + fn is_git_root_real_repo() { + let tmp = TempDir::new().unwrap(); + let git_dir = tmp.path().join(".git"); + fs::create_dir(&git_dir).unwrap(); + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + assert!(is_git_root(tmp.path())); + } + + #[test] + fn is_git_root_worktree() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join(".git"), "gitdir: /some/path").unwrap(); + assert!(is_git_root(tmp.path())); + } + + #[test] + fn is_git_root_empty_dir() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join(".git")).unwrap(); + assert!(!is_git_root(tmp.path())); + } + + #[test] + fn is_git_root_stray_file() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join(".git"), "random content").unwrap(); + assert!(!is_git_root(tmp.path())); + } + + #[test] + fn is_git_root_no_git() { + let tmp = TempDir::new().unwrap(); + assert!(!is_git_root(tmp.path())); + } } diff --git a/src/error.rs b/src/error.rs index e46a2eb..bac4c3d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,7 +15,7 @@ pub type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub enum Error { #[error( - "relocal.toml not found in {start_dir}. Run relocal from the project root, or run `relocal init` to create one." + "no relocal.toml or .git found in {start_dir}. Run relocal from a git repo root, or run `relocal init` to create a config." )] ConfigNotFound { start_dir: PathBuf }, diff --git a/src/main.rs b/src/main.rs index f669f3f..af37f1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,8 +30,8 @@ fn load_config() -> (PathBuf, config::Config) { (root, cfg) } -/// Resolves the session name: uses the explicit name if given, otherwise -/// derives it from the repo root directory name. +/// Resolves the session name: explicit name if given, otherwise hashed from +/// the repo root path and git origin. fn resolve_session(name: Option, repo_root: &Path) -> String { match name { Some(n) => { @@ -41,7 +41,7 @@ fn resolve_session(name: Option, repo_root: &Path) -> String { }); n } - None => session::default_session_name(repo_root).unwrap_or_else(|e| { + None => session::hashed_session_name(repo_root).unwrap_or_else(|e| { error!("{e}"); std::process::exit(1); }), diff --git a/src/rsync.rs b/src/rsync.rs index 7aa6b69..2a943e8 100644 --- a/src/rsync.rs +++ b/src/rsync.rs @@ -25,7 +25,7 @@ pub enum Direction { /// [`build_rsync_args`] call. [`ProcessRunner::run_rsync`](crate::runner::ProcessRunner) /// uses `direction` and `local_path` to validate the local destination on pull, /// refusing to run `rsync --delete` against a directory that doesn't contain -/// `relocal.toml`. +/// `relocal.toml` or `.git`. #[derive(Debug)] pub struct RsyncParams { args: Vec, diff --git a/src/runner.rs b/src/runner.rs index 725a4f0..c0afaf8 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -127,12 +127,11 @@ fn login_shell_wrap(command: &str) -> String { format!("bash -lc {quoted}") } -/// Validates that the local pull target is a relocal repo root. +/// Validates that the local pull target is a repo root. /// -/// Canonicalizes the path and checks for `relocal.toml` — the same marker -/// [`find_repo_root`](crate::discovery::find_repo_root) uses. This prevents -/// `rsync --delete` from wiping an unintended directory if a bug in -/// higher-level code passes the wrong `repo_root`. +/// Canonicalizes the path and delegates to [`find_repo_root`] for marker +/// validation. This prevents `rsync --delete` from wiping an unintended +/// directory if a bug in higher-level code passes the wrong `repo_root`. fn validate_local_pull_target(local_path: &Path) -> Result<()> { let canonical = local_path .canonicalize() @@ -143,15 +142,13 @@ fn validate_local_pull_target(local_path: &Path) -> Result<()> { local_path.display() ), })?; - if !canonical.join("relocal.toml").is_file() { - return Err(Error::CommandFailed { - command: "rsync".to_string(), - message: format!( - "refusing to pull: {} does not contain relocal.toml", - canonical.display() - ), - }); - } + crate::discovery::find_repo_root(&canonical).map_err(|_| Error::CommandFailed { + command: "rsync".to_string(), + message: format!( + "refusing to pull: {} does not contain relocal.toml or a valid .git", + canonical.display() + ), + })?; Ok(()) } @@ -256,12 +253,12 @@ mod tests { } #[test] - fn pull_refused_without_toml() { + fn pull_refused_without_markers() { let dir = tempfile::tempdir().unwrap(); let result = validate_local_pull_target(dir.path()); assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(err.contains("relocal.toml")); + assert!(err.contains("refusing to pull")); } #[test] @@ -272,17 +269,42 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn pull_allowed_with_git_dir() { + let dir = tempfile::tempdir().unwrap(); + let git_dir = dir.path().join(".git"); + std::fs::create_dir(&git_dir).unwrap(); + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let result = validate_local_pull_target(dir.path()); + assert!(result.is_ok()); + } + + #[test] + fn pull_allowed_with_git_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".git"), "gitdir: /some/path").unwrap(); + let result = validate_local_pull_target(dir.path()); + assert!(result.is_ok()); + } + + #[test] + fn pull_refused_with_stray_git_dir() { + let dir = tempfile::tempdir().unwrap(); + // .git dir without HEAD — not a real git repo + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let result = validate_local_pull_target(dir.path()); + assert!(result.is_err()); + } + #[test] fn push_skips_validation() { let runner = ProcessRunner::default(); - // Use a nonexistent path — push should not validate it. - // A pull with this path would fail validation, but push must not. let params = make_params(Direction::Push, PathBuf::from("/nonexistent/path")); let result = runner.run_rsync(¶ms); if let Err(e) = result { let msg = e.to_string(); assert!( - !msg.contains("relocal.toml"), + !msg.contains("refusing to pull"), "push should not validate local path" ); } @@ -292,10 +314,9 @@ mod tests { fn run_rsync_pull_rejects_invalid_destination() { let runner = ProcessRunner::default(); let dir = tempfile::tempdir().unwrap(); - // No relocal.toml — ProcessRunner::run_rsync must refuse before invoking rsync. let params = make_params(Direction::Pull, dir.path().to_path_buf()); let err = runner.run_rsync(¶ms).unwrap_err().to_string(); - assert!(err.contains("relocal.toml")); + assert!(err.contains("refusing to pull")); } #[test] diff --git a/src/session.rs b/src/session.rs index eb24faa..f1e0102 100644 --- a/src/session.rs +++ b/src/session.rs @@ -5,6 +5,9 @@ //! characters. use std::path::Path; +use std::process::Command; + +use sha2::{Digest, Sha256}; use crate::error::{Error, Result}; @@ -33,25 +36,74 @@ pub fn validate_session_name(name: &str) -> Result<()> { Ok(()) } -/// Derives a default session name from a directory path by taking its final -/// component (e.g., `/home/user/my-project` → `my-project`). Returns an error -/// if the directory name contains invalid characters. -pub fn default_session_name(path: &Path) -> Result { - let name = - path.file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| Error::InvalidSessionName { - name: path.display().to_string(), - reason: "cannot derive session name from directory path".to_string(), - })?; - - validate_session_name(name)?; - Ok(name.to_string()) +/// Derives a default session name with a hash suffix to prevent collisions +/// between different repos or checkouts. +/// +/// Format: `-<8-hex-chars>`. The hash is derived from the canonical +/// path and the git origin URL. The dirname prefix keeps it human-readable; +/// the hash prevents collisions. Returns an error if the directory name +/// contains characters invalid for session names. +pub fn hashed_session_name(repo_root: &Path) -> Result { + let dirname = repo_root + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| Error::InvalidSessionName { + name: repo_root.display().to_string(), + reason: "cannot derive session name from directory path".to_string(), + })?; + + validate_session_name(dirname)?; + + let canonical = repo_root + .canonicalize() + .unwrap_or_else(|_| repo_root.to_path_buf()); + let origin = git_origin_url(repo_root); + + let hash = compute_hash(canonical.as_os_str().as_encoded_bytes(), origin.as_bytes()); + Ok(format!("{dirname}-{hash}")) +} + +/// Reads the git origin URL for a repo, returning an empty string if +/// no origin is configured or git is not available. +fn git_origin_url(repo_root: &Path) -> String { + Command::new("git") + .current_dir(repo_root) + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + +/// First 8 hex chars of SHA-256(path + \0 + origin). +fn compute_hash(path_bytes: &[u8], origin_bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(path_bytes); + hasher.update(b"\0"); + hasher.update(origin_bytes); + let digest = hasher.finalize(); + format!( + "{:02x}{:02x}{:02x}{:02x}", + digest[0], digest[1], digest[2], digest[3] + ) } #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::TempDir; + + /// Runs a git command and asserts it succeeds. + fn git(dir: &std::path::Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("run git"); + assert!(out.status.success(), "git {:?} failed", args); + } #[test] fn valid_names() { @@ -89,20 +141,111 @@ mod tests { } #[test] - fn default_from_directory() { + fn hashed_name_is_deterministic() { + let path = Path::new("/home/user/my-project"); + let a = hashed_session_name(path).unwrap(); + let b = hashed_session_name(path).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn hashed_name_has_dirname_prefix() { let path = Path::new("/home/user/my-project"); - assert_eq!(default_session_name(path).unwrap(), "my-project"); + let name = hashed_session_name(path).unwrap(); + assert!(name.starts_with("my-project-"), "got: {name}"); + } + + #[test] + fn hashed_name_format_is_dirname_dash_8hex() { + let path = Path::new("/home/user/my-project"); + let name = hashed_session_name(path).unwrap(); + let parts: Vec<&str> = name.rsplitn(2, '-').collect(); + assert_eq!(parts[0].len(), 8, "suffix should be 8 hex chars: {name}"); + assert!(parts[0].chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(parts[1], "my-project"); + } + + #[test] + fn hash_is_8_hex_chars() { + let h = compute_hash(b"/some/path", b"git@example.com:repo.git"); + assert_eq!(h.len(), 8); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn hashed_name_different_paths_differ() { + let a = compute_hash(b"/home/alice/repo", b""); + let b = compute_hash(b"/home/bob/repo", b""); + assert_ne!(a, b); } #[test] - fn default_from_invalid_directory_name() { + fn hashed_name_different_origins_differ() { + let a = compute_hash(b"/same/path", b"git@github.com:alice/repo.git"); + let b = compute_hash(b"/same/path", b"git@github.com:bob/repo.git"); + assert_ne!(a, b); + } + + #[test] + fn hashed_name_non_existent_path_still_works() { + // canonicalize falls back to the raw path for non-existent dirs + let path = Path::new("/nonexistent/test-repo"); + let name = hashed_session_name(path).unwrap(); + assert!(name.starts_with("test-repo-"), "got: {name}"); + } + + #[test] + fn hashed_name_rejects_invalid_dirname() { let path = Path::new("/home/user/my.project"); - assert!(default_session_name(path).is_err()); + assert!(hashed_session_name(path).is_err()); } #[test] - fn default_from_root_path() { + fn hashed_name_root_path_errors() { let path = Path::new("/"); - assert!(default_session_name(path).is_err()); + assert!(hashed_session_name(path).is_err()); + } + + #[test] + fn hashed_name_reads_origin_from_git_repo() { + let tmp = TempDir::new().unwrap(); + // Use a subdirectory with a valid session-name-compatible name, + // since tempdir names contain dots which fail validation. + let repo = tmp.path().join("test-repo"); + fs::create_dir(&repo).unwrap(); + + git(&repo, &["init"]); + git( + &repo, + &["remote", "add", "origin", "git@github.com:test/repo.git"], + ); + + let with_origin = hashed_session_name(&repo).unwrap(); + + git(&repo, &["remote", "remove", "origin"]); + + let without_origin = hashed_session_name(&repo).unwrap(); + + assert!(with_origin.starts_with("test-repo-")); + assert!(without_origin.starts_with("test-repo-")); + assert_ne!(with_origin, without_origin, "origin should affect the hash"); + } + + #[test] + fn git_origin_url_returns_empty_for_non_repo() { + let tmp = TempDir::new().unwrap(); + assert_eq!(git_origin_url(tmp.path()), ""); + } + + #[test] + fn git_origin_url_reads_configured_origin() { + let tmp = TempDir::new().unwrap(); + git(tmp.path(), &["init"]); + git( + tmp.path(), + &["remote", "add", "origin", "https://example.com/repo.git"], + ); + + assert_eq!(git_origin_url(tmp.path()), "https://example.com/repo.git"); } } diff --git a/tests/integration.rs b/tests/integration.rs index eb13df9..9812a3b 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -68,6 +68,24 @@ fn make_local_repo_with_excludes(remote: &str, excludes: &[&str]) -> (tempfile:: (dir, config) } +/// Creates a local temp directory with only a `.git` repo (no `relocal.toml`). +/// Used to test config-free workflows where config comes from user config alone. +fn make_local_repo_git_only(remote: &str) -> (tempfile::TempDir, Config) { + let dir = tempfile::tempdir().expect("create temp dir"); + + let git_init = std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .expect("git init"); + assert!(git_init.status.success(), "git init failed"); + + // No relocal.toml — config must come from user config or be passed directly. + let toml = format!("remote = \"{remote}\"\n"); + let config = Config::parse(&toml).unwrap(); + (dir, config) +} + /// Returns the path to the compiled `relocal` binary for integration tests. fn relocal_bin() -> &'static str { env!("CARGO_BIN_EXE_relocal") @@ -279,6 +297,75 @@ fn push_excludes_claude_dir() { )); } +// --------------------------------------------------------------------------- +// Config-free (git-only) workflow tests +// --------------------------------------------------------------------------- + +/// Exercises the actual binary in a .git-only repo (no relocal.toml). +/// +/// Runs `relocal status ` from a directory that has `.git` but no +/// `relocal.toml`. This exercises repo root discovery (the .git fallback) +/// and merged config loading through the real binary, not just library calls. +/// +/// The user's `~/.relocal/config.toml` must provide `remote` for this test +/// to pass. If it doesn't exist, the test verifies the error is about config +/// (missing `remote`), not about discovery (missing `relocal.toml`). +#[test] +#[ignore = "requires RELOCAL_TEST_REMOTE"] +fn git_only_repo_discovery_through_binary() { + let remote = test_remote().unwrap(); + let session = unique_session("git-only-bin"); + let (dir, _config) = make_local_repo_git_only(&remote); + let _cleanup = RemoteCleanup { + remote: remote.clone(), + session: session.clone(), + }; + ensure_remote_session_dir(&remote, &session); + + let output = std::process::Command::new(relocal_bin()) + .args(["status", &session]) + .current_dir(dir.path()) + .output() + .expect("run relocal status in git-only repo"); + + let stderr = String::from_utf8_lossy(&output.stderr); + // The binary should succeed: discovery via .git, config from + // ~/.relocal/config.toml (which the test runner is expected to have). + assert!( + output.status.success(), + "relocal status should succeed in git-only repo: {stderr}" + ); +} + +#[test] +#[ignore = "requires RELOCAL_TEST_REMOTE"] +fn git_only_push_pull_round_trip() { + let remote = test_remote().unwrap(); + let session = unique_session("git-only"); + let (dir, config) = make_local_repo_git_only(&remote); + let _cleanup = RemoteCleanup { + remote: remote.clone(), + session: session.clone(), + }; + let runner = ProcessRunner::default(); + ensure_remote_session_dir(&remote, &session); + + std::fs::write(dir.path().join("hello.txt"), "from git-only").unwrap(); + sync::sync_push(&runner, &config, &session, dir.path(), false).unwrap(); + + let content = read_remote_file(&remote, &format!("{}/hello.txt", remote_dir(&session))); + assert_eq!(content.as_deref(), Some("from git-only")); + + write_remote_file( + &remote, + &format!("{}/hello.txt", remote_dir(&session)), + "modified-remote", + ); + sync::sync_pull(&runner, &config, &session, dir.path(), false).unwrap(); + let local = std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(); + assert_eq!(local, "modified-remote"); +} + // --------------------------------------------------------------------------- // Sync pull tests // ---------------------------------------------------------------------------