github-sync-agent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_sync_agent-0.1.0/PKG-INFO +132 -0
- github_sync_agent-0.1.0/README.md +113 -0
- github_sync_agent-0.1.0/github_sync/__init__.py +1 -0
- github_sync_agent-0.1.0/github_sync/agent.py +192 -0
- github_sync_agent-0.1.0/github_sync/cli.py +234 -0
- github_sync_agent-0.1.0/github_sync/config.py +35 -0
- github_sync_agent-0.1.0/github_sync/setup.py +146 -0
- github_sync_agent-0.1.0/github_sync_agent.egg-info/PKG-INFO +132 -0
- github_sync_agent-0.1.0/github_sync_agent.egg-info/SOURCES.txt +13 -0
- github_sync_agent-0.1.0/github_sync_agent.egg-info/dependency_links.txt +1 -0
- github_sync_agent-0.1.0/github_sync_agent.egg-info/entry_points.txt +2 -0
- github_sync_agent-0.1.0/github_sync_agent.egg-info/requires.txt +2 -0
- github_sync_agent-0.1.0/github_sync_agent.egg-info/top_level.txt +1 -0
- github_sync_agent-0.1.0/pyproject.toml +37 -0
- github_sync_agent-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: github-sync-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync all your local projects to GitHub automatically
|
|
5
|
+
Author: yuvalavni
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yuvalavni/Agents
|
|
8
|
+
Project-URL: Repository, https://github.com/yuvalavni/Agents
|
|
9
|
+
Keywords: github,git,sync,backup,automation,cli
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Requires-Dist: click>=8.0
|
|
19
|
+
|
|
20
|
+
# GitHub Sync Agent
|
|
21
|
+
|
|
22
|
+
Automatically syncs every project under `/home/yuval/projects` to GitHub (`github.com/yuvalavni`).
|
|
23
|
+
|
|
24
|
+
For each project it will:
|
|
25
|
+
1. `git init` if not already a repo
|
|
26
|
+
2. Create the GitHub repo if it doesn't exist yet
|
|
27
|
+
3. Ensure the remote uses SSH
|
|
28
|
+
4. Stage + commit any uncommitted changes
|
|
29
|
+
5. Push to `origin`
|
|
30
|
+
|
|
31
|
+
Runs daily at **02:00** via a systemd user timer, and on-demand via CLI.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Directory layout
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
agents/github-sync/
|
|
39
|
+
├── github_sync_agent.py # Main script
|
|
40
|
+
├── config.yaml # GitHub user, projects root, schedule, exclusions
|
|
41
|
+
├── requirements.txt
|
|
42
|
+
├── install.sh # One-time setup
|
|
43
|
+
├── systemd/
|
|
44
|
+
│ ├── github-sync.service
|
|
45
|
+
│ └── github-sync.timer
|
|
46
|
+
└── logs/
|
|
47
|
+
└── github-sync.log
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cd /home/yuval/projects/Agents/agents/github-sync
|
|
56
|
+
|
|
57
|
+
# 1. Install (once)
|
|
58
|
+
bash install.sh
|
|
59
|
+
|
|
60
|
+
# 2. Dry run
|
|
61
|
+
.venv/bin/python github_sync_agent.py --dry-run
|
|
62
|
+
|
|
63
|
+
# 3. Sync for real
|
|
64
|
+
.venv/bin/python github_sync_agent.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Configuration (`config.yaml`)
|
|
70
|
+
|
|
71
|
+
| Key | Default | Description |
|
|
72
|
+
|-----|---------|-------------|
|
|
73
|
+
| `github_user` | `yuvalavni` | GitHub username |
|
|
74
|
+
| `projects_root` | `/home/yuval/projects` | Scans all subdirectories here |
|
|
75
|
+
| `schedule_time` | `02:00` | Daily run time |
|
|
76
|
+
| `default_visibility` | `private` | `private` or `public` for new repos |
|
|
77
|
+
| `auto_commit_message` | `chore: auto-sync` | Commit message for auto-commits |
|
|
78
|
+
| `exclude` | `[]` | Directory names to skip |
|
|
79
|
+
| `log_file` | `logs/github-sync.log` | Log file path |
|
|
80
|
+
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
81
|
+
|
|
82
|
+
### Excluding a project
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
exclude:
|
|
86
|
+
- SomePrivateProject
|
|
87
|
+
- TempExperiment
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## CLI reference
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Sync all projects
|
|
96
|
+
.venv/bin/python github_sync_agent.py
|
|
97
|
+
|
|
98
|
+
# Sync a single project
|
|
99
|
+
.venv/bin/python github_sync_agent.py --project Agents
|
|
100
|
+
|
|
101
|
+
# Preview without making any changes
|
|
102
|
+
.venv/bin/python github_sync_agent.py --dry-run
|
|
103
|
+
|
|
104
|
+
# Show status of all projects (git, remote, changes, GitHub)
|
|
105
|
+
.venv/bin/python github_sync_agent.py --status
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Systemd
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# Trigger immediately
|
|
114
|
+
systemctl --user start github-sync.service
|
|
115
|
+
|
|
116
|
+
# Check next scheduled run
|
|
117
|
+
systemctl --user list-timers github-sync.timer
|
|
118
|
+
|
|
119
|
+
# View live logs
|
|
120
|
+
journalctl --user -u github-sync.service -f
|
|
121
|
+
|
|
122
|
+
# Disable daily timer
|
|
123
|
+
systemctl --user disable --now github-sync.timer
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Prerequisites
|
|
129
|
+
|
|
130
|
+
- `git` installed
|
|
131
|
+
- `gh` installed and authenticated (`gh auth status`)
|
|
132
|
+
- SSH key added to GitHub (`gh ssh-key list`)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# GitHub Sync Agent
|
|
2
|
+
|
|
3
|
+
Automatically syncs every project under `/home/yuval/projects` to GitHub (`github.com/yuvalavni`).
|
|
4
|
+
|
|
5
|
+
For each project it will:
|
|
6
|
+
1. `git init` if not already a repo
|
|
7
|
+
2. Create the GitHub repo if it doesn't exist yet
|
|
8
|
+
3. Ensure the remote uses SSH
|
|
9
|
+
4. Stage + commit any uncommitted changes
|
|
10
|
+
5. Push to `origin`
|
|
11
|
+
|
|
12
|
+
Runs daily at **02:00** via a systemd user timer, and on-demand via CLI.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Directory layout
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
agents/github-sync/
|
|
20
|
+
├── github_sync_agent.py # Main script
|
|
21
|
+
├── config.yaml # GitHub user, projects root, schedule, exclusions
|
|
22
|
+
├── requirements.txt
|
|
23
|
+
├── install.sh # One-time setup
|
|
24
|
+
├── systemd/
|
|
25
|
+
│ ├── github-sync.service
|
|
26
|
+
│ └── github-sync.timer
|
|
27
|
+
└── logs/
|
|
28
|
+
└── github-sync.log
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd /home/yuval/projects/Agents/agents/github-sync
|
|
37
|
+
|
|
38
|
+
# 1. Install (once)
|
|
39
|
+
bash install.sh
|
|
40
|
+
|
|
41
|
+
# 2. Dry run
|
|
42
|
+
.venv/bin/python github_sync_agent.py --dry-run
|
|
43
|
+
|
|
44
|
+
# 3. Sync for real
|
|
45
|
+
.venv/bin/python github_sync_agent.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Configuration (`config.yaml`)
|
|
51
|
+
|
|
52
|
+
| Key | Default | Description |
|
|
53
|
+
|-----|---------|-------------|
|
|
54
|
+
| `github_user` | `yuvalavni` | GitHub username |
|
|
55
|
+
| `projects_root` | `/home/yuval/projects` | Scans all subdirectories here |
|
|
56
|
+
| `schedule_time` | `02:00` | Daily run time |
|
|
57
|
+
| `default_visibility` | `private` | `private` or `public` for new repos |
|
|
58
|
+
| `auto_commit_message` | `chore: auto-sync` | Commit message for auto-commits |
|
|
59
|
+
| `exclude` | `[]` | Directory names to skip |
|
|
60
|
+
| `log_file` | `logs/github-sync.log` | Log file path |
|
|
61
|
+
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
62
|
+
|
|
63
|
+
### Excluding a project
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
exclude:
|
|
67
|
+
- SomePrivateProject
|
|
68
|
+
- TempExperiment
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## CLI reference
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Sync all projects
|
|
77
|
+
.venv/bin/python github_sync_agent.py
|
|
78
|
+
|
|
79
|
+
# Sync a single project
|
|
80
|
+
.venv/bin/python github_sync_agent.py --project Agents
|
|
81
|
+
|
|
82
|
+
# Preview without making any changes
|
|
83
|
+
.venv/bin/python github_sync_agent.py --dry-run
|
|
84
|
+
|
|
85
|
+
# Show status of all projects (git, remote, changes, GitHub)
|
|
86
|
+
.venv/bin/python github_sync_agent.py --status
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Systemd
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Trigger immediately
|
|
95
|
+
systemctl --user start github-sync.service
|
|
96
|
+
|
|
97
|
+
# Check next scheduled run
|
|
98
|
+
systemctl --user list-timers github-sync.timer
|
|
99
|
+
|
|
100
|
+
# View live logs
|
|
101
|
+
journalctl --user -u github-sync.service -f
|
|
102
|
+
|
|
103
|
+
# Disable daily timer
|
|
104
|
+
systemctl --user disable --now github-sync.timer
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Prerequisites
|
|
110
|
+
|
|
111
|
+
- `git` installed
|
|
112
|
+
- `gh` installed and authenticated (`gh auth status`)
|
|
113
|
+
- SSH key added to GitHub (`gh ssh-key list`)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Core sync logic — no CLI concerns here."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Shell helpers
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
def _run(cmd: list[str], cwd: Path | None = None) -> tuple[bool, str]:
|
|
13
|
+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
14
|
+
return result.returncode == 0, (result.stdout + result.stderr).strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Git helpers
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def is_git_repo(path: Path) -> bool:
|
|
22
|
+
ok, _ = _run(["git", "rev-parse", "--git-dir"], cwd=path)
|
|
23
|
+
return ok
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def git_init(path: Path, dry_run: bool) -> None:
|
|
27
|
+
if dry_run:
|
|
28
|
+
logging.info(f"[DRY-RUN] Would git init: {path}")
|
|
29
|
+
return
|
|
30
|
+
_run(["git", "init"], cwd=path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def has_remote(path: Path) -> bool:
|
|
34
|
+
ok, _ = _run(["git", "remote", "get-url", "origin"], cwd=path)
|
|
35
|
+
return ok
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_remote(path: Path, github_user: str, project: str, dry_run: bool) -> None:
|
|
39
|
+
url = f"git@github.com:{github_user}/{project}.git"
|
|
40
|
+
if dry_run:
|
|
41
|
+
logging.info(f"[DRY-RUN] Would add remote: {url}")
|
|
42
|
+
return
|
|
43
|
+
_run(["git", "remote", "add", "origin", url], cwd=path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_ssh_remote(path: Path, github_user: str, project: str, dry_run: bool) -> None:
|
|
47
|
+
_, url = _run(["git", "remote", "get-url", "origin"], cwd=path)
|
|
48
|
+
if url.startswith("https://"):
|
|
49
|
+
ssh_url = f"git@github.com:{github_user}/{project}.git"
|
|
50
|
+
if dry_run:
|
|
51
|
+
logging.info(f"[DRY-RUN] Would switch remote to SSH: {ssh_url}")
|
|
52
|
+
return
|
|
53
|
+
_run(["git", "remote", "set-url", "origin", ssh_url], cwd=path)
|
|
54
|
+
logging.info(f"Switched remote to SSH")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def has_commits(path: Path) -> bool:
|
|
58
|
+
ok, _ = _run(["git", "rev-parse", "HEAD"], cwd=path)
|
|
59
|
+
return ok
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def has_uncommitted_changes(path: Path) -> bool:
|
|
63
|
+
ok, output = _run(["git", "status", "--porcelain"], cwd=path)
|
|
64
|
+
return ok and bool(output.strip())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stage_and_commit(path: Path, message: str, dry_run: bool) -> bool:
|
|
68
|
+
if dry_run:
|
|
69
|
+
logging.info(f"[DRY-RUN] Would commit: '{message}'")
|
|
70
|
+
return True
|
|
71
|
+
_run(["git", "add", "-A"], cwd=path)
|
|
72
|
+
ok, _ = _run(["git", "diff", "--cached", "--quiet"], cwd=path)
|
|
73
|
+
if ok:
|
|
74
|
+
return False # nothing staged
|
|
75
|
+
_run(["git", "commit", "-m", message], cwd=path)
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def push(path: Path, dry_run: bool) -> bool:
|
|
80
|
+
if dry_run:
|
|
81
|
+
logging.info("[DRY-RUN] Would push to origin")
|
|
82
|
+
return True
|
|
83
|
+
ok, out = _run(["git", "push", "-u", "origin", "HEAD"], cwd=path)
|
|
84
|
+
if not ok:
|
|
85
|
+
logging.error(f"Push failed: {out}")
|
|
86
|
+
return ok
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_remote_url(path: Path) -> str:
|
|
90
|
+
_, url = _run(["git", "remote", "get-url", "origin"], cwd=path)
|
|
91
|
+
return url
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# GitHub helpers
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def github_repo_exists(github_user: str, project: str) -> bool:
|
|
99
|
+
ok, _ = _run(["gh", "repo", "view", f"{github_user}/{project}"])
|
|
100
|
+
return ok
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_github_repo(github_user: str, project: str, visibility: str, dry_run: bool) -> bool:
|
|
104
|
+
flag = "--private" if visibility == "private" else "--public"
|
|
105
|
+
if dry_run:
|
|
106
|
+
logging.info(f"[DRY-RUN] Would create GitHub repo: {github_user}/{project} ({visibility})")
|
|
107
|
+
return True
|
|
108
|
+
ok, out = _run(["gh", "repo", "create", f"{github_user}/{project}", flag])
|
|
109
|
+
if ok:
|
|
110
|
+
logging.info(f"Created GitHub repo: https://github.com/{github_user}/{project}")
|
|
111
|
+
else:
|
|
112
|
+
logging.error(f"Failed to create repo: {out}")
|
|
113
|
+
return ok
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Per-project sync
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def sync_project(path: Path, cfg: dict, dry_run: bool) -> bool:
|
|
121
|
+
project = path.name
|
|
122
|
+
github_user = cfg["github_user"]
|
|
123
|
+
|
|
124
|
+
logging.info(f"── {project}")
|
|
125
|
+
|
|
126
|
+
if not is_git_repo(path):
|
|
127
|
+
git_init(path, dry_run)
|
|
128
|
+
|
|
129
|
+
if not github_repo_exists(github_user, project):
|
|
130
|
+
if not create_github_repo(github_user, project, cfg["default_visibility"], dry_run):
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
if not has_remote(path):
|
|
134
|
+
add_remote(path, github_user, project, dry_run)
|
|
135
|
+
else:
|
|
136
|
+
ensure_ssh_remote(path, github_user, project, dry_run)
|
|
137
|
+
|
|
138
|
+
if not has_commits(path) or has_uncommitted_changes(path):
|
|
139
|
+
stage_and_commit(path, cfg["auto_commit_message"], dry_run)
|
|
140
|
+
|
|
141
|
+
if not has_commits(path):
|
|
142
|
+
logging.warning(" No commits yet — nothing to push.")
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
ok = push(path, dry_run)
|
|
146
|
+
if ok:
|
|
147
|
+
logging.info(" ✓ Synced")
|
|
148
|
+
return ok
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Discover projects
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def discover_projects(cfg: dict, project_name: str | None = None) -> list[Path]:
|
|
156
|
+
root = Path(cfg["projects_root"])
|
|
157
|
+
exclude = set(cfg.get("exclude", []))
|
|
158
|
+
|
|
159
|
+
if project_name:
|
|
160
|
+
path = root / project_name
|
|
161
|
+
if not path.is_dir():
|
|
162
|
+
raise ValueError(f"Project '{project_name}' not found in {root}")
|
|
163
|
+
return [path]
|
|
164
|
+
|
|
165
|
+
return sorted(
|
|
166
|
+
p for p in root.iterdir()
|
|
167
|
+
if p.is_dir() and p.name not in exclude and not p.name.startswith(".")
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Status table
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def get_status(cfg: dict) -> list[dict]:
|
|
176
|
+
root = Path(cfg["projects_root"])
|
|
177
|
+
exclude = set(cfg.get("exclude", []))
|
|
178
|
+
github_user = cfg["github_user"]
|
|
179
|
+
rows = []
|
|
180
|
+
|
|
181
|
+
for path in sorted(root.iterdir()):
|
|
182
|
+
if not path.is_dir() or path.name in exclude or path.name.startswith("."):
|
|
183
|
+
continue
|
|
184
|
+
rows.append({
|
|
185
|
+
"project": path.name,
|
|
186
|
+
"git": is_git_repo(path),
|
|
187
|
+
"remote": is_git_repo(path) and has_remote(path),
|
|
188
|
+
"changes": has_uncommitted_changes(path) if is_git_repo(path) else False,
|
|
189
|
+
"on_github": github_repo_exists(github_user, path.name),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return rows
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from . import agent, config, setup
|
|
9
|
+
|
|
10
|
+
LOG_FORMAT = "%(asctime)s %(levelname)-8s %(message)s"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _setup_logging(cfg: dict) -> None:
|
|
14
|
+
log_file = cfg["log_file"]
|
|
15
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=getattr(logging, cfg["log_level"].upper(), logging.INFO),
|
|
18
|
+
format=LOG_FORMAT,
|
|
19
|
+
handlers=[
|
|
20
|
+
logging.StreamHandler(sys.stdout),
|
|
21
|
+
logging.FileHandler(log_file, encoding="utf-8"),
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Root command group
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
@click.group()
|
|
31
|
+
@click.version_option(__version__, prog_name="github-sync")
|
|
32
|
+
@click.option("--config", "config_path", type=click.Path(path_type=Path),
|
|
33
|
+
default=None, help="Path to config.yaml")
|
|
34
|
+
@click.pass_context
|
|
35
|
+
def cli(ctx: click.Context, config_path: Path | None) -> None:
|
|
36
|
+
"""GitHub Sync Agent — sync local projects to GitHub."""
|
|
37
|
+
ctx.ensure_object(dict)
|
|
38
|
+
try:
|
|
39
|
+
ctx.obj["cfg"] = config.load(config_path)
|
|
40
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
41
|
+
click.echo(f"Error: {exc}", err=True)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# sync
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
@cli.command()
|
|
50
|
+
@click.option("--project", "-p", default=None,
|
|
51
|
+
help="Sync a single project by name (default: all)")
|
|
52
|
+
@click.option("--dry-run", is_flag=True,
|
|
53
|
+
help="Show what would happen without making changes")
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def sync(ctx: click.Context, project: str | None, dry_run: bool) -> None:
|
|
56
|
+
"""Sync projects to GitHub."""
|
|
57
|
+
cfg = ctx.obj["cfg"]
|
|
58
|
+
_setup_logging(cfg)
|
|
59
|
+
|
|
60
|
+
if dry_run:
|
|
61
|
+
logging.info("=== DRY-RUN — no changes will be made ===")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
projects = agent.discover_projects(cfg, project)
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
click.echo(f"Error: {exc}", err=True)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
logging.info(f"Syncing {len(projects)} project(s) → github.com/{cfg['github_user']}")
|
|
70
|
+
|
|
71
|
+
results = []
|
|
72
|
+
for path in projects:
|
|
73
|
+
ok = agent.sync_project(path, cfg, dry_run)
|
|
74
|
+
results.append((path.name, ok))
|
|
75
|
+
|
|
76
|
+
successes = sum(1 for _, ok in results if ok)
|
|
77
|
+
failures = len(results) - successes
|
|
78
|
+
logging.info(f"Done: {successes} succeeded, {failures} failed.")
|
|
79
|
+
|
|
80
|
+
if failures:
|
|
81
|
+
for name, ok in results:
|
|
82
|
+
if not ok:
|
|
83
|
+
logging.error(f" FAILED: {name}")
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# status
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
@cli.command()
|
|
92
|
+
@click.pass_context
|
|
93
|
+
def status(ctx: click.Context) -> None:
|
|
94
|
+
"""Show sync status for all projects."""
|
|
95
|
+
cfg = ctx.obj["cfg"]
|
|
96
|
+
rows = agent.get_status(cfg)
|
|
97
|
+
|
|
98
|
+
click.echo(f"\nProjects root : {cfg['projects_root']}")
|
|
99
|
+
click.echo(f"GitHub user : {cfg['github_user']}\n")
|
|
100
|
+
|
|
101
|
+
header = f"{'Project':<25} {'Git':<6} {'Remote':<8} {'Changes':<10} {'On GitHub'}"
|
|
102
|
+
click.echo(header)
|
|
103
|
+
click.echo("─" * len(header))
|
|
104
|
+
|
|
105
|
+
for row in rows:
|
|
106
|
+
click.echo(
|
|
107
|
+
f"{row['project']:<25} "
|
|
108
|
+
f"{'✓' if row['git'] else '✗':<6} "
|
|
109
|
+
f"{'✓' if row['remote'] else '✗':<8} "
|
|
110
|
+
f"{'dirty' if row['changes'] else 'clean':<10} "
|
|
111
|
+
f"{'✓' if row['on_github'] else '✗'}"
|
|
112
|
+
)
|
|
113
|
+
click.echo()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# init
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@cli.command()
|
|
121
|
+
def init() -> None:
|
|
122
|
+
"""Interactive setup wizard — run this first on a new machine."""
|
|
123
|
+
click.echo()
|
|
124
|
+
click.echo(click.style("=== GitHub Sync Agent — Setup Wizard ===", bold=True))
|
|
125
|
+
click.echo()
|
|
126
|
+
|
|
127
|
+
# ── 1. Check git ────────────────────────────────────────────────────────
|
|
128
|
+
git_ok, git_ver = setup.check_git()
|
|
129
|
+
if git_ok:
|
|
130
|
+
click.echo(f" ✓ git found: {git_ver}")
|
|
131
|
+
else:
|
|
132
|
+
click.echo(click.style(" ✗ git is not installed.", fg="red"))
|
|
133
|
+
click.echo(" Install it with: sudo apt-get install -y git")
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
# ── 2. Check gh ─────────────────────────────────────────────────────────
|
|
137
|
+
gh_ok, gh_ver = setup.check_gh()
|
|
138
|
+
if gh_ok:
|
|
139
|
+
click.echo(f" ✓ gh found: {gh_ver}")
|
|
140
|
+
else:
|
|
141
|
+
click.echo(click.style(" ✗ gh (GitHub CLI) is not installed.", fg="red"))
|
|
142
|
+
click.echo(" Install it with: sudo apt-get install -y gh")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
# ── 3. GitHub authentication ─────────────────────────────────────────────
|
|
146
|
+
if setup.gh_authenticated():
|
|
147
|
+
github_user = setup.get_github_username()
|
|
148
|
+
click.echo(f" ✓ Authenticated as: {github_user}")
|
|
149
|
+
else:
|
|
150
|
+
click.echo(click.style(" ✗ Not authenticated with GitHub.", fg="yellow"))
|
|
151
|
+
click.echo(" Running: gh auth login")
|
|
152
|
+
click.echo()
|
|
153
|
+
import subprocess
|
|
154
|
+
subprocess.run(["gh", "auth", "login"])
|
|
155
|
+
github_user = setup.get_github_username()
|
|
156
|
+
if not github_user:
|
|
157
|
+
click.echo(click.style("Authentication failed. Exiting.", fg="red"))
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
click.echo(f" ✓ Authenticated as: {github_user}")
|
|
160
|
+
|
|
161
|
+
click.echo()
|
|
162
|
+
|
|
163
|
+
# ── 4. Projects root ────────────────────────────────────────────────────
|
|
164
|
+
default_root = str(Path.home() / "projects")
|
|
165
|
+
projects_root = click.prompt(
|
|
166
|
+
" Projects root directory (all subdirs will be synced)",
|
|
167
|
+
default=default_root,
|
|
168
|
+
)
|
|
169
|
+
projects_root = str(Path(projects_root).expanduser().resolve())
|
|
170
|
+
if not Path(projects_root).exists():
|
|
171
|
+
create = click.confirm(f" Directory '{projects_root}' does not exist. Create it?", default=True)
|
|
172
|
+
if create:
|
|
173
|
+
Path(projects_root).mkdir(parents=True)
|
|
174
|
+
click.echo(f" Created: {projects_root}")
|
|
175
|
+
else:
|
|
176
|
+
click.echo(" Aborted.")
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
|
|
179
|
+
# ── 5. Default visibility ───────────────────────────────────────────────
|
|
180
|
+
visibility = click.prompt(
|
|
181
|
+
" Default repo visibility for new GitHub repos",
|
|
182
|
+
type=click.Choice(["private", "public"]),
|
|
183
|
+
default="private",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
click.echo()
|
|
187
|
+
|
|
188
|
+
# ── 6. SSH key setup ────────────────────────────────────────────────────
|
|
189
|
+
click.echo(click.style(" Setting up SSH key for GitHub...", bold=True))
|
|
190
|
+
|
|
191
|
+
if not setup.ssh_key_exists():
|
|
192
|
+
click.echo(" No SSH key found — generating one...")
|
|
193
|
+
if setup.generate_ssh_key():
|
|
194
|
+
click.echo(" ✓ SSH key generated")
|
|
195
|
+
else:
|
|
196
|
+
click.echo(click.style(" ✗ Failed to generate SSH key.", fg="red"))
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
else:
|
|
199
|
+
click.echo(" ✓ SSH key already exists")
|
|
200
|
+
|
|
201
|
+
setup.add_github_host_key()
|
|
202
|
+
|
|
203
|
+
if not setup.ssh_key_on_github():
|
|
204
|
+
click.echo(" Uploading SSH key to GitHub...")
|
|
205
|
+
if setup.upload_ssh_key_to_github():
|
|
206
|
+
click.echo(" ✓ SSH key added to GitHub")
|
|
207
|
+
else:
|
|
208
|
+
click.echo(click.style(" ✗ Failed to upload SSH key.", fg="yellow"))
|
|
209
|
+
click.echo(" Run manually: gh ssh-key add ~/.ssh/id_ed25519.pub")
|
|
210
|
+
else:
|
|
211
|
+
click.echo(" ✓ SSH key already on GitHub")
|
|
212
|
+
|
|
213
|
+
click.echo()
|
|
214
|
+
|
|
215
|
+
# ── 7. Write config ─────────────────────────────────────────────────────
|
|
216
|
+
config_path = setup.write_config(github_user, projects_root, visibility)
|
|
217
|
+
click.echo(click.style(f" ✓ Config written to: {config_path}", fg="green"))
|
|
218
|
+
|
|
219
|
+
click.echo()
|
|
220
|
+
click.echo(click.style("=== Setup complete! ===", bold=True))
|
|
221
|
+
click.echo()
|
|
222
|
+
click.echo("Next steps:")
|
|
223
|
+
click.echo(f" github-sync status # see all your projects")
|
|
224
|
+
click.echo(f" github-sync sync --dry-run # preview what will be synced")
|
|
225
|
+
click.echo(f" github-sync sync # sync everything to GitHub")
|
|
226
|
+
click.echo()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Entry point
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def main() -> None:
|
|
234
|
+
cli()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import yaml
|
|
3
|
+
|
|
4
|
+
DEFAULT_CONFIG = Path.home() / ".config" / "github-sync" / "config.yaml"
|
|
5
|
+
FALLBACK_CONFIG = Path(__file__).parent.parent / "config.yaml"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load(path: Path | None = None) -> dict:
|
|
9
|
+
"""Load config from path, or search default locations."""
|
|
10
|
+
candidates = [path, DEFAULT_CONFIG, FALLBACK_CONFIG]
|
|
11
|
+
for p in candidates:
|
|
12
|
+
if p and p.exists():
|
|
13
|
+
with open(p) as f:
|
|
14
|
+
cfg = yaml.safe_load(f)
|
|
15
|
+
_apply_defaults(cfg)
|
|
16
|
+
return cfg
|
|
17
|
+
raise FileNotFoundError(
|
|
18
|
+
f"No config file found. Create one at {DEFAULT_CONFIG} or pass --config."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _apply_defaults(cfg: dict) -> None:
|
|
23
|
+
cfg.setdefault("schedule_time", "02:00")
|
|
24
|
+
cfg.setdefault("default_visibility", "private")
|
|
25
|
+
cfg.setdefault("auto_commit_message", "chore: auto-sync")
|
|
26
|
+
cfg.setdefault("exclude", [])
|
|
27
|
+
cfg.setdefault("log_file", str(Path.home() / ".local" / "share" / "github-sync" / "github-sync.log"))
|
|
28
|
+
cfg.setdefault("log_level", "INFO")
|
|
29
|
+
# Expand ~ in projects_root
|
|
30
|
+
if "projects_root" in cfg:
|
|
31
|
+
cfg["projects_root"] = str(Path(cfg["projects_root"]).expanduser())
|
|
32
|
+
|
|
33
|
+
for key in ("github_user", "projects_root"):
|
|
34
|
+
if not cfg.get(key):
|
|
35
|
+
raise ValueError(f"Missing required config key: '{key}'")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Setup helpers used by `github-sync init`.
|
|
3
|
+
Checks prerequisites, detects credentials, sets up SSH.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Dependency checks
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
def check_git() -> tuple[bool, str]:
|
|
16
|
+
"""Returns (installed, version_string)."""
|
|
17
|
+
if not shutil.which("git"):
|
|
18
|
+
return False, ""
|
|
19
|
+
result = subprocess.run(["git", "--version"], capture_output=True, text=True)
|
|
20
|
+
return True, result.stdout.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_gh() -> tuple[bool, str]:
|
|
24
|
+
"""Returns (installed, version_string)."""
|
|
25
|
+
if not shutil.which("gh"):
|
|
26
|
+
return False, ""
|
|
27
|
+
result = subprocess.run(["gh", "--version"], capture_output=True, text=True)
|
|
28
|
+
return True, result.stdout.splitlines()[0].strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def gh_authenticated() -> bool:
|
|
32
|
+
result = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
|
|
33
|
+
return result.returncode == 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_github_username() -> str | None:
|
|
37
|
+
"""Detect the currently authenticated GitHub username."""
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["gh", "api", "user", "--jq", ".login"],
|
|
40
|
+
capture_output=True, text=True,
|
|
41
|
+
)
|
|
42
|
+
if result.returncode == 0:
|
|
43
|
+
return result.stdout.strip()
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# SSH helpers
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
DEFAULT_KEY = Path.home() / ".ssh" / "id_ed25519"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ssh_key_exists() -> bool:
|
|
55
|
+
return DEFAULT_KEY.exists()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generate_ssh_key(passphrase: str = "") -> bool:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["ssh-keygen", "-t", "ed25519", "-C", "github-sync", "-f", str(DEFAULT_KEY), "-N", passphrase],
|
|
61
|
+
capture_output=True, text=True,
|
|
62
|
+
)
|
|
63
|
+
return result.returncode == 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_github_host_key() -> bool:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["ssh-keyscan", "-t", "ed25519", "github.com"],
|
|
69
|
+
capture_output=True, text=True,
|
|
70
|
+
)
|
|
71
|
+
if result.returncode != 0:
|
|
72
|
+
return False
|
|
73
|
+
known_hosts = Path.home() / ".ssh" / "known_hosts"
|
|
74
|
+
known_hosts.parent.mkdir(mode=0o700, exist_ok=True)
|
|
75
|
+
existing = known_hosts.read_text() if known_hosts.exists() else ""
|
|
76
|
+
if "github.com" not in existing:
|
|
77
|
+
with open(known_hosts, "a") as f:
|
|
78
|
+
f.write(result.stdout)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ssh_key_on_github() -> bool:
|
|
83
|
+
"""Test if the current SSH key authenticates to GitHub."""
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "git@github.com"],
|
|
86
|
+
capture_output=True, text=True,
|
|
87
|
+
)
|
|
88
|
+
return "successfully authenticated" in (result.stdout + result.stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def upload_ssh_key_to_github(title: str = "github-sync") -> bool:
|
|
92
|
+
pub_key = Path(str(DEFAULT_KEY) + ".pub")
|
|
93
|
+
if not pub_key.exists():
|
|
94
|
+
return False
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["gh", "ssh-key", "add", str(pub_key), "--title", title],
|
|
97
|
+
capture_output=True, text=True,
|
|
98
|
+
)
|
|
99
|
+
return result.returncode == 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Config writer
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
CONFIG_DIR = Path.home() / ".config" / "github-sync"
|
|
107
|
+
CONFIG_PATH = CONFIG_DIR / "config.yaml"
|
|
108
|
+
|
|
109
|
+
CONFIG_TEMPLATE = """\
|
|
110
|
+
# GitHub Sync Agent — configuration
|
|
111
|
+
# Generated by `github-sync init`
|
|
112
|
+
|
|
113
|
+
github_user: {github_user}
|
|
114
|
+
|
|
115
|
+
# Root folder — every subdirectory here is treated as a project to sync
|
|
116
|
+
projects_root: {projects_root}
|
|
117
|
+
|
|
118
|
+
# Daily schedule time (used by the systemd timer / cron)
|
|
119
|
+
schedule_time: "02:00"
|
|
120
|
+
|
|
121
|
+
# Default visibility for newly created GitHub repos: private | public
|
|
122
|
+
default_visibility: {visibility}
|
|
123
|
+
|
|
124
|
+
# Commit message used for auto-commits
|
|
125
|
+
auto_commit_message: "chore: auto-sync"
|
|
126
|
+
|
|
127
|
+
# Project directories to skip (names only, not full paths)
|
|
128
|
+
exclude: []
|
|
129
|
+
|
|
130
|
+
# Logging
|
|
131
|
+
log_file: {log_file}
|
|
132
|
+
log_level: INFO
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def write_config(github_user: str, projects_root: str, visibility: str) -> Path:
|
|
137
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
log_file = str(Path.home() / ".local" / "share" / "github-sync" / "github-sync.log")
|
|
139
|
+
content = CONFIG_TEMPLATE.format(
|
|
140
|
+
github_user=github_user,
|
|
141
|
+
projects_root=projects_root,
|
|
142
|
+
visibility=visibility,
|
|
143
|
+
log_file=log_file,
|
|
144
|
+
)
|
|
145
|
+
CONFIG_PATH.write_text(content)
|
|
146
|
+
return CONFIG_PATH
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: github-sync-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync all your local projects to GitHub automatically
|
|
5
|
+
Author: yuvalavni
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yuvalavni/Agents
|
|
8
|
+
Project-URL: Repository, https://github.com/yuvalavni/Agents
|
|
9
|
+
Keywords: github,git,sync,backup,automation,cli
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Requires-Dist: click>=8.0
|
|
19
|
+
|
|
20
|
+
# GitHub Sync Agent
|
|
21
|
+
|
|
22
|
+
Automatically syncs every project under `/home/yuval/projects` to GitHub (`github.com/yuvalavni`).
|
|
23
|
+
|
|
24
|
+
For each project it will:
|
|
25
|
+
1. `git init` if not already a repo
|
|
26
|
+
2. Create the GitHub repo if it doesn't exist yet
|
|
27
|
+
3. Ensure the remote uses SSH
|
|
28
|
+
4. Stage + commit any uncommitted changes
|
|
29
|
+
5. Push to `origin`
|
|
30
|
+
|
|
31
|
+
Runs daily at **02:00** via a systemd user timer, and on-demand via CLI.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Directory layout
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
agents/github-sync/
|
|
39
|
+
├── github_sync_agent.py # Main script
|
|
40
|
+
├── config.yaml # GitHub user, projects root, schedule, exclusions
|
|
41
|
+
├── requirements.txt
|
|
42
|
+
├── install.sh # One-time setup
|
|
43
|
+
├── systemd/
|
|
44
|
+
│ ├── github-sync.service
|
|
45
|
+
│ └── github-sync.timer
|
|
46
|
+
└── logs/
|
|
47
|
+
└── github-sync.log
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cd /home/yuval/projects/Agents/agents/github-sync
|
|
56
|
+
|
|
57
|
+
# 1. Install (once)
|
|
58
|
+
bash install.sh
|
|
59
|
+
|
|
60
|
+
# 2. Dry run
|
|
61
|
+
.venv/bin/python github_sync_agent.py --dry-run
|
|
62
|
+
|
|
63
|
+
# 3. Sync for real
|
|
64
|
+
.venv/bin/python github_sync_agent.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Configuration (`config.yaml`)
|
|
70
|
+
|
|
71
|
+
| Key | Default | Description |
|
|
72
|
+
|-----|---------|-------------|
|
|
73
|
+
| `github_user` | `yuvalavni` | GitHub username |
|
|
74
|
+
| `projects_root` | `/home/yuval/projects` | Scans all subdirectories here |
|
|
75
|
+
| `schedule_time` | `02:00` | Daily run time |
|
|
76
|
+
| `default_visibility` | `private` | `private` or `public` for new repos |
|
|
77
|
+
| `auto_commit_message` | `chore: auto-sync` | Commit message for auto-commits |
|
|
78
|
+
| `exclude` | `[]` | Directory names to skip |
|
|
79
|
+
| `log_file` | `logs/github-sync.log` | Log file path |
|
|
80
|
+
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
81
|
+
|
|
82
|
+
### Excluding a project
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
exclude:
|
|
86
|
+
- SomePrivateProject
|
|
87
|
+
- TempExperiment
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## CLI reference
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Sync all projects
|
|
96
|
+
.venv/bin/python github_sync_agent.py
|
|
97
|
+
|
|
98
|
+
# Sync a single project
|
|
99
|
+
.venv/bin/python github_sync_agent.py --project Agents
|
|
100
|
+
|
|
101
|
+
# Preview without making any changes
|
|
102
|
+
.venv/bin/python github_sync_agent.py --dry-run
|
|
103
|
+
|
|
104
|
+
# Show status of all projects (git, remote, changes, GitHub)
|
|
105
|
+
.venv/bin/python github_sync_agent.py --status
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Systemd
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# Trigger immediately
|
|
114
|
+
systemctl --user start github-sync.service
|
|
115
|
+
|
|
116
|
+
# Check next scheduled run
|
|
117
|
+
systemctl --user list-timers github-sync.timer
|
|
118
|
+
|
|
119
|
+
# View live logs
|
|
120
|
+
journalctl --user -u github-sync.service -f
|
|
121
|
+
|
|
122
|
+
# Disable daily timer
|
|
123
|
+
systemctl --user disable --now github-sync.timer
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Prerequisites
|
|
129
|
+
|
|
130
|
+
- `git` installed
|
|
131
|
+
- `gh` installed and authenticated (`gh auth status`)
|
|
132
|
+
- SSH key added to GitHub (`gh ssh-key list`)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
github_sync/__init__.py
|
|
4
|
+
github_sync/agent.py
|
|
5
|
+
github_sync/cli.py
|
|
6
|
+
github_sync/config.py
|
|
7
|
+
github_sync/setup.py
|
|
8
|
+
github_sync_agent.egg-info/PKG-INFO
|
|
9
|
+
github_sync_agent.egg-info/SOURCES.txt
|
|
10
|
+
github_sync_agent.egg-info/dependency_links.txt
|
|
11
|
+
github_sync_agent.egg-info/entry_points.txt
|
|
12
|
+
github_sync_agent.egg-info/requires.txt
|
|
13
|
+
github_sync_agent.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github_sync
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "github-sync-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Sync all your local projects to GitHub automatically"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "yuvalavni" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["github", "git", "sync", "backup", "automation", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pyyaml>=6.0",
|
|
25
|
+
"click>=8.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/yuvalavni/Agents"
|
|
30
|
+
Repository = "https://github.com/yuvalavni/Agents"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
github-sync = "github_sync.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["."]
|
|
37
|
+
include = ["github_sync*"]
|