Skip to content

Commit 6826cd6

Browse files
committed
Enhance README and CLI with remote persistence features
- Updated README to clarify the purpose of `monodev` and introduced the concept of Local-First Overlays. - Added new CLI commands for managing remote persistence: `push` and `pull`, allowing users to share stores across machines and teams. - Implemented functionality to configure remote settings, including selecting a remote and setting the persistence branch. - Enhanced commit logic to notify users of removed paths that are no longer tracked. - Introduced a SnapshotManager for materializing and dematerializing stores between local and remote directories. - Added tests for remote configuration management and snapshot functionality.
1 parent 87e4949 commit 6826cd6

23 files changed

Lines changed: 2201 additions & 42 deletions

File tree

README.md

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
# monodev
22

3-
I frequently work with a giant monorepo consisting of countless components nested at varying depths. Because of its scale, it has a noticeable memory footprint on my machine and imposes subotimal landscape for AI agents.
3+
Most codebases suffer from "local file drift." We generate debug scripts, AI scratchpads (.cursorrules, .claude, etc.), and task notes that live alongside our code but don't belong in the repo. These files are either accidentally committed (clutter) or deleted too soon (lost knowledge).
44

5-
To work around this, I selectively open individual components as isolated IDE workspaces, manually excluding irrelevant directories via settings.json. And during developing, I would additionally add various component-specific artifacts such as .cursor, Makefile, AGENTS.md, run.py, etc.
5+
`monodev` introduces a third space: **Local-First Overlays**. It keeps your dev-only artifacts persistent and portable without ever leaking them into your Git history.
66

7-
Long story short, these dev/component specific artifacts cannot easily be commited and persisted methodically, making their reusability difficult across different branches and sessions.
8-
9-
So I created `monodev`.
10-
11-
`monodev` is a local-only CLI for managing **reusable development overlays** (scripts, editor config, agent-instructions, Makefiles, etc.) across large monorepos.
12-
13-
It lets you:
14-
- keep dev-only files out of git
15-
- persist them safely per component or profile
16-
- re-apply and remove them deterministically
7+
The Monodev Way:
8+
- **Invisible**: Keeps "git status" clean.
9+
- **Persistent**: Your notes, scripts, and agent files survive branch switches.
10+
- **Portable**: Push/Pull your local state via hidden orphan branches.
1711

1812
---
1913

@@ -22,9 +16,19 @@ It lets you:
2216
Platform support: macOS (Apple Silicon only)
2317

2418
```bash
19+
# 1. Install
2520
brew install danieljhkim/tap/monodev
2621

27-
monodev version
22+
# 2. Create your first store and track a file
23+
monodev checkout -n my-debug-tools
24+
monodev track debug_helper.py
25+
monodev commit --all
26+
27+
# 3. Remove the overlay after done working
28+
monodev unapply
29+
30+
# 4. Reapply again later when needed
31+
monodev apply
2832

2933
monodev help
3034
```
@@ -194,20 +198,59 @@ monodev stack apply [--force] [--dry-run]
194198
monodev stack unapply [--force] [--dry-run]
195199
```
196200

201+
### Remote persistence
202+
203+
Share stores across machines and teams using Git-based remote persistence. Stores are pushed to a separate orphan branch (`monodev/persist` by default) to keep them isolated from your main repository history.
204+
205+
```bash
206+
monodev init # initialize the .monodev directory in the repository root
207+
208+
# Configure which Git remote to use for persistence
209+
monodev remote use origin
210+
211+
# Show current remote configuration
212+
monodev remote show
213+
214+
# Set a custom persistence branch (optional)
215+
monodev remote set-branch monodev/custom
216+
217+
# Push existing stores to remote
218+
monodev push <store-id>...
219+
220+
# Pull stores from remote
221+
monodev pull <store-id>...
222+
223+
# Pull and verify checksums
224+
monodev pull <store-id>... --verify
225+
226+
# Force pull (overwrite local stores)
227+
monodev pull <store-id>... --force
228+
```
229+
230+
**How it works:**
231+
232+
1. Remote configuration is stored locally at `.monodev/remote.json`
233+
2. Stores are materialized to `.monodev/persist/stores/` before pushing
234+
3. A separate Git repository is created at `.monodev/.git` with an orphan branch
235+
4. The orphan branch is pushed to your configured remote
236+
5. When pulling, stores are fetched and dematerialized to `~/.monodev/stores/`
237+
238+
This approach keeps persistence separate from your main Git history while leveraging Git's compression and deduplication.
239+
197240
---
198241

199-
## What monodev is (and isnt)
242+
## What monodev is (and isn't)
200243

201244
**Is**
202245
- per-workspace dev overlay manager
203-
- designed for monorepos
246+
- designed for monorepos and large codebases
204247
- deterministic
248+
- portable
205249

206250
**Is not**
207251
- a build system
208252
- a dependency manager
209253
- a replacement for dotfiles or Nix
210-
- always reversible
211254

212255
---
213256

internal/cli/commit.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ var commitCmd = &cobra.Command{
6161
PrintWarning(fmt.Sprintf("Would skip %s (not found in workspace):", PrintCount(len(result.Missing), "missing path", "missing paths")))
6262
PrintList(result.Missing, 1)
6363
}
64+
if len(result.Removed) > 0 {
65+
fmt.Println()
66+
PrintInfo(fmt.Sprintf("Would remove %s from store (no longer tracked):", PrintCount(len(result.Removed), "path", "paths")))
67+
PrintList(result.Removed, 1)
68+
}
6469
return nil
6570
}
6671

@@ -72,6 +77,10 @@ var commitCmd = &cobra.Command{
7277
PrintWarning(fmt.Sprintf("Missing %s (not found in workspace):", PrintCount(len(result.Missing), "path", "paths")))
7378
PrintList(result.Missing, 1)
7479
}
80+
if len(result.Removed) > 0 {
81+
PrintInfo(fmt.Sprintf("Removed %s from store (no longer tracked):", PrintCount(len(result.Removed), "path", "paths")))
82+
PrintList(result.Removed, 1)
83+
}
7584
return nil
7685
},
7786
}

internal/cli/common.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import (
1111
"github.com/danieljhkim/monodev/internal/fsops"
1212
"github.com/danieljhkim/monodev/internal/gitx"
1313
"github.com/danieljhkim/monodev/internal/hash"
14+
"github.com/danieljhkim/monodev/internal/persist"
15+
"github.com/danieljhkim/monodev/internal/remote"
1416
"github.com/danieljhkim/monodev/internal/state"
1517
"github.com/danieljhkim/monodev/internal/stores"
18+
"github.com/danieljhkim/monodev/internal/sync"
1619
)
1720

1821
// newEngine creates a new engine with real implementations of all dependencies.
@@ -40,6 +43,33 @@ func newEngine() (*engine.Engine, error) {
4043
return engine.New(gitRepo, storeRepo, stateStore, fs, hasher, clk, *paths), nil
4144
}
4245

46+
// newSyncer creates a new syncer with real implementations of all dependencies.
47+
func newSyncer() (*sync.Syncer, error) {
48+
// Get default paths
49+
paths, err := config.DefaultPaths()
50+
if err != nil {
51+
return nil, fmt.Errorf("failed to get config paths: %w", err)
52+
}
53+
54+
// Ensure directories exist
55+
if err := paths.EnsureDirectories(); err != nil {
56+
return nil, fmt.Errorf("failed to ensure directories: %w", err)
57+
}
58+
59+
// Create real implementations
60+
fs := fsops.NewRealFS()
61+
hasher := hash.NewSHA256Hasher()
62+
clk := &clock.RealClock{}
63+
stateStore := state.NewFileStateStore(fs, paths.Workspaces)
64+
storeRepo := stores.NewFileStoreRepo(fs, paths.Stores)
65+
gitPersist := remote.NewRealGitPersistence()
66+
configStore := remote.NewFileRemoteConfigStore(fs)
67+
snapshotMgr := persist.NewSnapshotManager(fs)
68+
69+
// Create syncer
70+
return sync.New(gitPersist, storeRepo, stateStore, snapshotMgr, configStore, fs, hasher, clk), nil
71+
}
72+
4373
// formatJSON formats a value as JSON.
4474
func formatJSON(v interface{}) (string, error) {
4575
data, err := json.MarshalIndent(v, "", " ")

internal/cli/format.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func PrintSubsection(title string) {
4646
// PrintSuccess prints a success message with a checkmark
4747
func PrintSuccess(msg string) {
4848
initColors()
49+
fmt.Println()
4950
_, _ = successColor.Printf("✓ %s\n", msg)
5051
}
5152

internal/cli/pull.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/danieljhkim/monodev/internal/gitx"
8+
"github.com/danieljhkim/monodev/internal/sync"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var pullCmd = &cobra.Command{
13+
Use: "pull [store-id...]",
14+
Short: "Pull stores from remote persistence repository",
15+
Long: `Pull stores from the remote persistence repository.
16+
17+
Fetches stores from the separate Git orphan branch at monodev/persist
18+
and restores them to ~/.monodev/stores/.
19+
20+
If no store IDs are specified, pulls all stores from the remote.
21+
22+
Examples:
23+
# Pull all stores from remote
24+
monodev pull
25+
26+
# Pull a single store
27+
monodev pull my-store
28+
29+
# Pull multiple stores
30+
monodev pull store1 store2
31+
32+
# Pull and verify checksums
33+
monodev pull my-store --verify
34+
35+
# Force pull (overwrite local changes)
36+
monodev pull my-store --force`,
37+
Args: cobra.ArbitraryArgs,
38+
RunE: runPull,
39+
}
40+
41+
var (
42+
pullRemote string
43+
pullForce bool
44+
pullVerify bool
45+
)
46+
47+
func init() {
48+
pullCmd.Flags().StringVar(&pullRemote, "remote", "", "Git remote to pull from (defaults to configured remote)")
49+
pullCmd.Flags().BoolVar(&pullForce, "force", false, "Force pull (overwrite local stores)")
50+
pullCmd.Flags().BoolVar(&pullVerify, "verify", false, "Verify store integrity with checksums after pulling")
51+
}
52+
53+
func runPull(cmd *cobra.Command, args []string) error {
54+
ctx := context.Background()
55+
56+
// Get the repository root
57+
gitRepo := gitx.NewRealGitRepo()
58+
repoRoot, err := gitRepo.Discover(".")
59+
if err != nil {
60+
return fmt.Errorf("not in a git repository: %w", err)
61+
}
62+
63+
// Create syncer
64+
syncer, err := newSyncer()
65+
if err != nil {
66+
return fmt.Errorf("failed to create syncer: %w", err)
67+
}
68+
69+
// Build request
70+
req := &sync.PullRequest{
71+
RepoRoot: repoRoot,
72+
StoreIDs: args,
73+
Remote: pullRemote,
74+
Force: pullForce,
75+
Verify: pullVerify,
76+
}
77+
78+
// Execute pull
79+
result, err := syncer.PullStore(ctx, req)
80+
if err != nil {
81+
return err
82+
}
83+
84+
// Display result
85+
if len(result.PulledStores) > 0 {
86+
if len(args) == 0 {
87+
PrintSuccess(fmt.Sprintf("Pulled all stores (%d):", len(result.PulledStores)))
88+
} else {
89+
PrintSuccess("Pulled stores:")
90+
}
91+
for _, storeID := range result.PulledStores {
92+
fmt.Printf(" - %s\n", storeID)
93+
}
94+
PrintInfo("")
95+
} else {
96+
PrintInfo("No stores found in remote")
97+
}
98+
99+
if result.Verified {
100+
PrintSuccess("All stores verified successfully")
101+
PrintInfo("")
102+
}
103+
104+
PrintInfo(fmt.Sprintf("Remote: %s", result.Remote))
105+
PrintInfo(fmt.Sprintf("Branch: %s", result.Branch))
106+
107+
return nil
108+
}

0 commit comments

Comments
 (0)