Metadata-Version: 2.5
Name: hermes-setu
Version: 0.2.0
Summary: Mirror a local git branch to a working tree on a remote agent host and synchronise commits in both directions.
Author-email: Jayesh Badwaik <j.badwaik@fz-juelich.de>
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Environment :: Console
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Version Control :: Git
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# hermes-setu

`hermes-setu` mirrors a local git worktree to a working tree on a remote host,
where an agent can work on it and commit, and synchronises commits between the
two. Remote paths are derived from the local path relative to `$HOME`, and the
mapping is kept in a local registry, so that the daily interface is:

```sh
hermes-setu add      # in any directory of a worktree; prints the remote path
hermes-setu pull     # bring the agent's commits here
hermes-setu push     # make the remote tree match the local branch
hermes-setu delete   # remove the remote tree
hermes-setu prune    # remove remote state whose local counterpart is gone
```

The remote host requires only `sshd`, a POSIX shell and git; nothing is
installed there.

## Requirements

- Local: Python ≥ 3.11, git ≥ 2.31.
- Remote: git ≥ 2.23, reachable with non-interactive `ssh` (key or agent).

## Installation

```sh
uv tool install git+https://<host>/<owner>/hermes-setu
# or, from a checkout
uv tool install .
```

Configure a host once:

```sh
hermes-setu setup coding-agent --root ~/agent-work
```

`setup` creates `<root>/repos` and `<root>/trees` on the host, records the
absolute root, and makes the first configured host the default.

## Path mapping

`add` locates the top of the enclosing worktree and its repository, and derives
both remote paths from their location relative to `$HOME`:

| Local | Remote |
|---|---|
| `~/src/proj` (worktree) | `<root>/trees/src/proj` |
| `~/src/proj/.git` (repository) | `<root>/repos/src/proj.git` (hub) |
| `~/wt/proj-feature` (linked worktree of the same repository) | `<root>/trees/wt/proj-feature`, same hub |
| submodule `lib/inner` of `~/src/proj` | `<root>/repos/src/proj.modules/lib--inner.git` |

Paths outside `$HOME`, and `$HOME` itself, are rejected.

## Commands

| Command | Effect |
|---|---|
| `setup NAME [--ssh DEST] [--root DIR]` | Records a host and creates its root directory. |
| `add [PATH] [--host NAME]` | Registers the worktree containing `PATH` (default: current directory), creates the hub and the remote tree for its current branch, and prints the tree path. If already registered, prints the path and changes nothing. |
| `push [--ff-only]` | Makes the remote branch and tree identical to the local branch, including submodules. If a different branch is checked out locally than the remote tree follows, the remote tree switches to it. |
| `pull [--accept-foreign]` | Fetches the remote branch and its submodule commits, checks them, and integrates them. |
| `status`, `path`, `shell` | Comparison with the remote tree, remote tree path, interactive shell in the tree. |
| `list` | All registered worktrees; entries whose local worktree is gone or replaced are marked `[stale]`. |
| `delete [--force]` | Removes the remote tree and its branch. Removes the hub, its sub-hubs and the local git remote when no tree of the repository remains. Refuses while the remote has commits not in the local branch or uncommitted changes. |
| `prune [-n] [--host NAME]` | See below. |

`-C PATH` works as in git.

## Synchronisation semantics

The local branch is authoritative.

**push** replaces the state of the remote branch and tree with the local branch.
Nothing on the remote is discarded without a reference:

| Remote state before push | Preserved as |
|---|---|
| Commits not contained in the local branch | `refs/setu/backup/<UTC time>/<branch>` |
| Commits on a detached `HEAD` | `refs/setu/backup/<UTC time>/<branch>.head` |
| Uncommitted and untracked files | `refs/setu/backup/<UTC time>/<branch>.wip` (a stash commit) |
| Uncommitted files inside submodules | the stash of that submodule in the tree |

Rebase, merge, cherry-pick and revert operations in progress in the tree are
quit before the update. Backups are fetched locally as
`agent-backup/<UTC time>/<branch>`, for example:

```sh
git log agent-backup/20261027T171500Z/main
git stash apply agent-backup/20261027T171500Z/main.wip
```

`push --ff-only` refuses instead of replacing remote commits.

**pull** requires the locally checked-out branch to be the one the remote tree
follows. It fetches the remote branch and, before integrating, the submodule
commits it references. It then

- does nothing if the local branch already contains the remote branch;
- fast-forwards if possible;
- otherwise merges. Conflicting submodule gitlinks are resolved to the local
  commit. Conflicts in files are left for manual resolution, as with `git merge`.

A subsequent `push` sends the merge result to the tree. Uncommitted changes in
the tree are never transferred; `pull` reports their number.

Without submodules, `git fetch agent` and `git pull agent <branch>` also work.

## Replaced and removed paths

Each hub records the machine it was created from (`machine` in the
configuration, by default the host name), the local repository path and the
repository's root commit. Each remote tree records the local worktree path it
mirrors. A local path counts as *replaced* when it no longer holds a working
tree of the recorded repository, as identified by its git directory and root
commit.

`add` handles a replaced path directly: if the hub or the tree at the mirrored
remote path belongs to a repository that no longer exists at its recorded local
path, it is removed and recreated for the current repository.

`prune` removes, for every configured host (or `--host`):

- registrations whose local worktree no longer exists or was replaced;
- hubs, together with their sub-hubs and trees, whose local repository no longer
  exists or no longer contains the recorded root commit;
- trees whose local worktree no longer exists or was replaced. The tree's
  branch is removed from the hub only if its head is contained in a local
  branch; otherwise the branch is kept and reported;
- registrations whose remote tree no longer exists.

Empty parent directories under `<root>/trees` and `<root>/repos` are removed.
Hubs recorded for another machine are never touched. `prune -n` prints the
actions without performing them. Rewriting a repository's root commit (for
example with `git filter-repo`) makes its hub count as replaced.

## Commit identity

The hub and every submodule clone in the trees are configured with the commit
identity from `[identity]` in the configuration file, falling back to the local
repository's `user.name` and `user.email`. A `commit-msg` hook in the hub, also
used by the submodule clones, removes lines matching the `strip` patterns, by
default:

```
^ *Co-Authored-By:.*Claude
^ *Claude-Session:
Generated with \[Claude Code\]
```

The hook can be bypassed (`git commit --no-verify`, `--author`, environment
variables). `pull` therefore checks every incoming commit: if its author or
committer email differs from the configured identity, or its message contains a
line matching a `strip` pattern, nothing is merged and the commits are listed.
`--accept-foreign` overrides the check for one pull; `enforce = false` disables
it. Submodule commits are not checked.

## Remote layout

```
<root>/
  repos/<path>.git                bare hub: branches, incoming/*, backups
  repos/<path>.modules/<name>.git bare sub-hub per submodule (nested: <a>--<b>.git)
  trees/<path>                    linked worktree of a hub
```

The hub holds `refs/heads/<branch>`, which is checked out in the tree and
receives the agent's commits, and `refs/heads/incoming/<branch>`, which receives
the local branch on `push`. Local pushes therefore never target a checked-out
branch.

## Submodules

`push` sends each commit referenced by a gitlink, recursively, to the
submodule's sub-hub as `refs/setu/pinned/<sha>`, then updates the tree with
`git submodule update --init --force` against the sub-hubs. The submodule URLs
in `.gitmodules` are not used on the remote host.

`pull` first copies commits that the agent made inside submodules from the tree
into the sub-hubs, then fetches them into the local submodule repositories, then
integrates the superproject and runs `git submodule update --recursive`.

Submodules must be initialised locally (`git submodule update --init
--recursive`). A submodule that the agent adds is reported and not fetched.

## Configuration

`~/.config/hermes-setu/config.toml` (`$XDG_CONFIG_HOME` and `$HERMES_SETU_CONFIG`
are honoured):

```toml
default = "coding-agent"
# machine = "laptop"         # name recorded in hubs; default: host name

[hosts."coding-agent"]
transport = "ssh"            # or "local": a directory on this machine
ssh = "coding-agent"         # ssh destination, default: the host name
root = "/home/jayesh/agent-work"
remote_name = "agent"        # name of the git remote in local repositories
ssh_options = []             # extra arguments for ssh
# multiplex = false          # disable ssh connection sharing

[identity]
name = "Jayesh Badwaik"
email = "j.badwaik@fz-juelich.de"
enforce = true
strip = ["^ *Co-Authored-By:.*Claude", "^ *Claude-Session:", "Generated with \\[Claude Code\\]"]
```

`add` configures the git remote in the local repository:

```
[remote "agent"]
    url = coding-agent:/home/jayesh/agent-work/repos/src/proj.git
    fetch = +refs/heads/*:refs/remotes/agent/*
    fetch = ^refs/heads/incoming/*
    fetch = +refs/setu/backup/*:refs/remotes/agent-backup/*
```

## Local state

| Path | Content |
|---|---|
| `~/.local/share/hermes-setu/registry.json` | registered worktrees: host, repository, root commit, branch, hub and tree paths (`$XDG_DATA_HOME` is honoured) |
| `~/.local/var/hermes-setu/lock` | lock serialising commands that change state |
| `~/.local/var/hermes-setu/log.jsonl` | one record per invocation: time, directory, arguments, exit status, error |
| `~/.local/tmp/hermes-setu/ssh-*` | ssh control sockets; one connection is shared by all ssh and git operations of a command and kept for 60 s |

## Limitations

- Only committed state is transferred in either direction.
- `push` replaces the tree while the agent may be working in it. The agent's
  state is preserved as backups, but its working tree is interrupted.
- A remote tree is removed together with any directories below it, including
  trees of other repositories nested at deeper paths.
- Git LFS objects are not transferred.

## Development

```sh
uv sync
uv run pytest
```

The tests run every command against a local transport and, for the ssh code
paths, against a stand-in `ssh` executable.

## License

Apache License 2.0; see [LICENSE](LICENSE).
