tlc-shared-docs 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.
- tlc_shared_docs-0.1.0/PKG-INFO +148 -0
- tlc_shared_docs-0.1.0/README.md +133 -0
- tlc_shared_docs-0.1.0/pyproject.toml +27 -0
- tlc_shared_docs-0.1.0/tlc_shared_docs/__init__.py +3 -0
- tlc_shared_docs-0.1.0/tlc_shared_docs/cli.py +95 -0
- tlc_shared_docs-0.1.0/tlc_shared_docs/config.py +228 -0
- tlc_shared_docs-0.1.0/tlc_shared_docs/core.py +353 -0
- tlc_shared_docs-0.1.0/tlc_shared_docs/git_ops.py +200 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tlc-shared-docs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Share documentation files between Git repositories
|
|
5
|
+
Requires-Python: >=3.9,<4.0
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Dist: gitpython (>=3.1,<4.0)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# tlc-shared-docs
|
|
16
|
+
|
|
17
|
+
Share documentation files between Git repositories. Pull files from a remote repo into your local docs tree, or push local files back — all configured through a single `shared.json`.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install tlc-shared-docs
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
### 1. Create the config
|
|
28
|
+
|
|
29
|
+
Create `docs/source/shared/shared.json` in your project:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"source_repo": {
|
|
34
|
+
"url": "https://github.com/your-org/shared-docs.git",
|
|
35
|
+
"branch": "main"
|
|
36
|
+
},
|
|
37
|
+
"shared_files": [
|
|
38
|
+
{
|
|
39
|
+
"remote_path": "guides/getting-started.md",
|
|
40
|
+
"local_path": "getting-started.md",
|
|
41
|
+
"action": "get"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"remote_path": "guides/api-reference.md",
|
|
45
|
+
"local_path": "api-reference.md",
|
|
46
|
+
"action": "push"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Pull shared files
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
tlc-shared-docs get
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This fetches every file with `"action": "get"` from the remote repo and saves it locally.
|
|
59
|
+
|
|
60
|
+
### 3. Push local files
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
tlc-shared-docs push
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This pushes every file with `"action": "push"` to the remote repo. If a remote file has changed since you last pulled, the command aborts with a conflict warning. Use `--force` to overwrite:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
tlc-shared-docs push --force
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 4. Preview changes
|
|
73
|
+
|
|
74
|
+
Both commands support `--dry-run`:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
tlc-shared-docs get --dry-run
|
|
78
|
+
tlc-shared-docs push --dry-run
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Configuration Reference
|
|
82
|
+
|
|
83
|
+
### `shared.json`
|
|
84
|
+
|
|
85
|
+
| Field | Description |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `source_repo.url` | Git clone URL for the shared repo |
|
|
88
|
+
| `source_repo.branch` | Branch to pull from / push to (default: `main`) |
|
|
89
|
+
| `shared_files[].remote_path` | Path to the file in the remote repo (supports glob patterns for `get`) |
|
|
90
|
+
| `shared_files[].local_path` | Local destination path (relative to `docs/source/shared/`) |
|
|
91
|
+
| `shared_files[].action` | `get` (pull from remote) or `push` (push to remote). Default: `get` |
|
|
92
|
+
|
|
93
|
+
### Wildcard / glob patterns
|
|
94
|
+
|
|
95
|
+
The `remote_path` field supports glob patterns for `get` actions, allowing you to fetch multiple files with a single entry:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"remote_path": "stories/**/*",
|
|
100
|
+
"local_path": "stories",
|
|
101
|
+
"action": "get"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Supported patterns:
|
|
106
|
+
- `*` — matches any file in a single directory (e.g., `docs/*.md`)
|
|
107
|
+
- `**/*` — matches files recursively across directories (e.g., `stories/**/*`)
|
|
108
|
+
- `?` — matches a single character (e.g., `chapter?.md`)
|
|
109
|
+
- `[seq]` — matches any character in the set (e.g., `file[0-9].txt`)
|
|
110
|
+
|
|
111
|
+
When using globs, `local_path` acts as the **destination directory**. Matched files preserve their directory structure relative to the non-glob prefix of the pattern. For example:
|
|
112
|
+
|
|
113
|
+
| Pattern | Matched remote file | `local_path` | Written to |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `stories/**/*` | `stories/ch1/intro.md` | `mystories` | `mystories/ch1/intro.md` |
|
|
116
|
+
| `Global/*.gitignore` | `Global/Vim.gitignore` | `ignores` | `ignores/Vim.gitignore` |
|
|
117
|
+
| `*.md` | `README.md` | `docs` | `docs/README.md` |
|
|
118
|
+
|
|
119
|
+
### Local path resolution
|
|
120
|
+
|
|
121
|
+
- **Relative paths** (e.g., `guide.md`) resolve relative to `docs/source/shared/`.
|
|
122
|
+
- **Absolute paths** (starting with `/`, e.g., `/src/docs/guide.md`) resolve relative to the project root.
|
|
123
|
+
|
|
124
|
+
### Git ignore
|
|
125
|
+
|
|
126
|
+
On first run, `tlc-shared-docs get` creates `docs/source/shared/` with a `.gitignore` that tracks only `shared.json` — all fetched files are ignored so they don't bloat your repo.
|
|
127
|
+
|
|
128
|
+
The auto-generated `docs/source/shared/.gitignore` contains:
|
|
129
|
+
|
|
130
|
+
```gitignore
|
|
131
|
+
# Auto-generated by tlc-shared-docs
|
|
132
|
+
# Ignore all fetched shared files; only track the config
|
|
133
|
+
*
|
|
134
|
+
!.gitignore
|
|
135
|
+
!shared.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This means:
|
|
139
|
+
- `shared.json` is committed to your repo (so teammates share the same config)
|
|
140
|
+
- All fetched/pushed doc files are ignored locally
|
|
141
|
+
- The `.gitignore` itself is also tracked
|
|
142
|
+
|
|
143
|
+
## Requirements
|
|
144
|
+
|
|
145
|
+
- Python 3.9+
|
|
146
|
+
- Git installed and on `PATH`
|
|
147
|
+
- Valid Git credentials for the source repo
|
|
148
|
+
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# tlc-shared-docs
|
|
2
|
+
|
|
3
|
+
Share documentation files between Git repositories. Pull files from a remote repo into your local docs tree, or push local files back — all configured through a single `shared.json`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tlc-shared-docs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### 1. Create the config
|
|
14
|
+
|
|
15
|
+
Create `docs/source/shared/shared.json` in your project:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"source_repo": {
|
|
20
|
+
"url": "https://github.com/your-org/shared-docs.git",
|
|
21
|
+
"branch": "main"
|
|
22
|
+
},
|
|
23
|
+
"shared_files": [
|
|
24
|
+
{
|
|
25
|
+
"remote_path": "guides/getting-started.md",
|
|
26
|
+
"local_path": "getting-started.md",
|
|
27
|
+
"action": "get"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"remote_path": "guides/api-reference.md",
|
|
31
|
+
"local_path": "api-reference.md",
|
|
32
|
+
"action": "push"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2. Pull shared files
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
tlc-shared-docs get
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This fetches every file with `"action": "get"` from the remote repo and saves it locally.
|
|
45
|
+
|
|
46
|
+
### 3. Push local files
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
tlc-shared-docs push
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This pushes every file with `"action": "push"` to the remote repo. If a remote file has changed since you last pulled, the command aborts with a conflict warning. Use `--force` to overwrite:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
tlc-shared-docs push --force
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 4. Preview changes
|
|
59
|
+
|
|
60
|
+
Both commands support `--dry-run`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
tlc-shared-docs get --dry-run
|
|
64
|
+
tlc-shared-docs push --dry-run
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration Reference
|
|
68
|
+
|
|
69
|
+
### `shared.json`
|
|
70
|
+
|
|
71
|
+
| Field | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `source_repo.url` | Git clone URL for the shared repo |
|
|
74
|
+
| `source_repo.branch` | Branch to pull from / push to (default: `main`) |
|
|
75
|
+
| `shared_files[].remote_path` | Path to the file in the remote repo (supports glob patterns for `get`) |
|
|
76
|
+
| `shared_files[].local_path` | Local destination path (relative to `docs/source/shared/`) |
|
|
77
|
+
| `shared_files[].action` | `get` (pull from remote) or `push` (push to remote). Default: `get` |
|
|
78
|
+
|
|
79
|
+
### Wildcard / glob patterns
|
|
80
|
+
|
|
81
|
+
The `remote_path` field supports glob patterns for `get` actions, allowing you to fetch multiple files with a single entry:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"remote_path": "stories/**/*",
|
|
86
|
+
"local_path": "stories",
|
|
87
|
+
"action": "get"
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Supported patterns:
|
|
92
|
+
- `*` — matches any file in a single directory (e.g., `docs/*.md`)
|
|
93
|
+
- `**/*` — matches files recursively across directories (e.g., `stories/**/*`)
|
|
94
|
+
- `?` — matches a single character (e.g., `chapter?.md`)
|
|
95
|
+
- `[seq]` — matches any character in the set (e.g., `file[0-9].txt`)
|
|
96
|
+
|
|
97
|
+
When using globs, `local_path` acts as the **destination directory**. Matched files preserve their directory structure relative to the non-glob prefix of the pattern. For example:
|
|
98
|
+
|
|
99
|
+
| Pattern | Matched remote file | `local_path` | Written to |
|
|
100
|
+
|---|---|---|---|
|
|
101
|
+
| `stories/**/*` | `stories/ch1/intro.md` | `mystories` | `mystories/ch1/intro.md` |
|
|
102
|
+
| `Global/*.gitignore` | `Global/Vim.gitignore` | `ignores` | `ignores/Vim.gitignore` |
|
|
103
|
+
| `*.md` | `README.md` | `docs` | `docs/README.md` |
|
|
104
|
+
|
|
105
|
+
### Local path resolution
|
|
106
|
+
|
|
107
|
+
- **Relative paths** (e.g., `guide.md`) resolve relative to `docs/source/shared/`.
|
|
108
|
+
- **Absolute paths** (starting with `/`, e.g., `/src/docs/guide.md`) resolve relative to the project root.
|
|
109
|
+
|
|
110
|
+
### Git ignore
|
|
111
|
+
|
|
112
|
+
On first run, `tlc-shared-docs get` creates `docs/source/shared/` with a `.gitignore` that tracks only `shared.json` — all fetched files are ignored so they don't bloat your repo.
|
|
113
|
+
|
|
114
|
+
The auto-generated `docs/source/shared/.gitignore` contains:
|
|
115
|
+
|
|
116
|
+
```gitignore
|
|
117
|
+
# Auto-generated by tlc-shared-docs
|
|
118
|
+
# Ignore all fetched shared files; only track the config
|
|
119
|
+
*
|
|
120
|
+
!.gitignore
|
|
121
|
+
!shared.json
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This means:
|
|
125
|
+
- `shared.json` is committed to your repo (so teammates share the same config)
|
|
126
|
+
- All fetched/pushed doc files are ignored locally
|
|
127
|
+
- The `.gitignore` itself is also tracked
|
|
128
|
+
|
|
129
|
+
## Requirements
|
|
130
|
+
|
|
131
|
+
- Python 3.9+
|
|
132
|
+
- Git installed and on `PATH`
|
|
133
|
+
- Valid Git credentials for the source repo
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "tlc-shared-docs"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Share documentation files between Git repositories"
|
|
5
|
+
authors = []
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{include = "tlc_shared_docs"}]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.scripts]
|
|
10
|
+
tlc-shared-docs = "tlc_shared_docs.cli:main"
|
|
11
|
+
|
|
12
|
+
[tool.poetry.dependencies]
|
|
13
|
+
python = "^3.9"
|
|
14
|
+
gitpython = "^3.1"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
pytest = "^8.0"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
markers = [
|
|
26
|
+
"integration: marks tests that hit real git repos (deselect with '-m \"not integration\"')",
|
|
27
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Command-line interface for tlc-shared-docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from tlc_shared_docs import __version__
|
|
9
|
+
from tlc_shared_docs.core import get_files, push_files
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="tlc-shared-docs",
|
|
15
|
+
description="Share documentation files between Git repositories.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
sub = parser.add_subparsers(dest="command")
|
|
22
|
+
|
|
23
|
+
# --- get: pull shared files from the remote repo ---
|
|
24
|
+
get_parser = sub.add_parser("get", help="Pull shared files from the remote repo")
|
|
25
|
+
get_parser.add_argument(
|
|
26
|
+
"--dry-run",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Show what would be done without making changes",
|
|
29
|
+
)
|
|
30
|
+
get_parser.add_argument(
|
|
31
|
+
"--central",
|
|
32
|
+
metavar="URL",
|
|
33
|
+
default=None,
|
|
34
|
+
help="Use central control mode: fetch config from this repo URL",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# --- push: push local shared files to the remote repo ---
|
|
38
|
+
push_parser = sub.add_parser("push", help="Push local shared files to the remote repo")
|
|
39
|
+
push_parser.add_argument(
|
|
40
|
+
"--dry-run",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Show what would be done without making changes",
|
|
43
|
+
)
|
|
44
|
+
push_parser.add_argument(
|
|
45
|
+
"--force",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Force-push even if remote files have changed",
|
|
48
|
+
)
|
|
49
|
+
push_parser.add_argument(
|
|
50
|
+
"--central",
|
|
51
|
+
metavar="URL",
|
|
52
|
+
default=None,
|
|
53
|
+
help="Use central control mode: fetch config from this repo URL",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return parser
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> None:
|
|
60
|
+
"""Entry point for the CLI. Parses arguments and dispatches to
|
|
61
|
+
the appropriate get/push handler."""
|
|
62
|
+
parser = _build_parser()
|
|
63
|
+
args = parser.parse_args(argv)
|
|
64
|
+
|
|
65
|
+
if args.command is None:
|
|
66
|
+
parser.print_help()
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# Dispatch to the correct command handler
|
|
71
|
+
if args.command == "get":
|
|
72
|
+
messages = get_files(dry_run=args.dry_run, central_url=args.central)
|
|
73
|
+
elif args.command == "push":
|
|
74
|
+
messages = push_files(dry_run=args.dry_run, force=args.force, central_url=args.central)
|
|
75
|
+
else:
|
|
76
|
+
parser.print_help()
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
for msg in messages:
|
|
80
|
+
print(msg)
|
|
81
|
+
|
|
82
|
+
# Exit with error code if there were conflicts or aborted operations
|
|
83
|
+
if any("CONFLICT" in m or "aborted" in m for m in messages):
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
except FileNotFoundError as exc:
|
|
87
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Configuration loading and project root detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
SHARED_DIR = Path("docs") / "source" / "shared"
|
|
17
|
+
CONFIG_FILE = "shared.json"
|
|
18
|
+
HASHES_FILE = ".shared-hashes.json"
|
|
19
|
+
CENTRAL_CONFIG_DIR = ".configs"
|
|
20
|
+
|
|
21
|
+
# .gitignore content for the shared directory:
|
|
22
|
+
# ignore everything except the config and .gitignore itself
|
|
23
|
+
GITIGNORE_CONTENT = """\
|
|
24
|
+
# Auto-generated by tlc-shared-docs
|
|
25
|
+
# Ignore all fetched shared files; only track the config
|
|
26
|
+
*
|
|
27
|
+
!.gitignore
|
|
28
|
+
!shared.json
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SourceRepo:
|
|
34
|
+
url: str
|
|
35
|
+
branch: str = "main"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SharedFile:
|
|
40
|
+
remote_path: str
|
|
41
|
+
local_path: str
|
|
42
|
+
action: str = "get" # "get" or "push"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SharedConfig:
|
|
47
|
+
source_repo: SourceRepo
|
|
48
|
+
shared_files: List[SharedFile] = field(default_factory=list)
|
|
49
|
+
mode: str = "local" # "local" or "central"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_GLOB_CHARS = set("*?[")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_glob(path: str) -> bool:
|
|
56
|
+
"""Return True if *path* contains glob wildcard characters."""
|
|
57
|
+
return bool(_GLOB_CHARS & set(path))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def glob_prefix(pattern: str) -> str:
|
|
61
|
+
"""Return the non-glob prefix of a pattern.
|
|
62
|
+
|
|
63
|
+
For ``stories/**/*.md`` this returns ``stories``.
|
|
64
|
+
For ``*.md`` this returns an empty string.
|
|
65
|
+
"""
|
|
66
|
+
# Split on forward slashes and collect segments until we hit a glob char
|
|
67
|
+
parts = pattern.replace("\\", "/").split("/")
|
|
68
|
+
prefix_parts = []
|
|
69
|
+
for p in parts:
|
|
70
|
+
if _GLOB_CHARS & set(p):
|
|
71
|
+
break
|
|
72
|
+
prefix_parts.append(p)
|
|
73
|
+
return "/".join(prefix_parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_project_root(start: Optional[Path] = None) -> Path:
|
|
77
|
+
"""Walk up from *start* (default: cwd) to find the nearest directory
|
|
78
|
+
that contains a ``.git`` folder, ``pyproject.toml``, or ``setup.py``."""
|
|
79
|
+
current = (start or Path.cwd()).resolve()
|
|
80
|
+
for directory in [current, *current.parents]:
|
|
81
|
+
if any((directory / marker).exists() for marker in (".git", "pyproject.toml", "setup.py")):
|
|
82
|
+
return directory
|
|
83
|
+
raise FileNotFoundError(
|
|
84
|
+
"Could not locate project root (no .git, pyproject.toml, or setup.py found)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def shared_dir_path(project_root: Path) -> Path:
|
|
89
|
+
"""Return the shared directory path for the given project root."""
|
|
90
|
+
return project_root / SHARED_DIR
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def config_path(project_root: Path) -> Path:
|
|
94
|
+
"""Return the shared.json config path for the given project root."""
|
|
95
|
+
return shared_dir_path(project_root) / CONFIG_FILE
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def hashes_path(project_root: Path) -> Path:
|
|
99
|
+
"""Return the .shared-hashes.json path for the given project root."""
|
|
100
|
+
return shared_dir_path(project_root) / HASHES_FILE
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_shared_dir(project_root: Path) -> Path:
|
|
104
|
+
"""Create the shared directory and its .gitignore if they don't exist."""
|
|
105
|
+
sdir = shared_dir_path(project_root)
|
|
106
|
+
sdir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
# Write .gitignore to keep fetched docs out of version control
|
|
109
|
+
gitignore = sdir / ".gitignore"
|
|
110
|
+
if not gitignore.exists():
|
|
111
|
+
gitignore.write_text(GITIGNORE_CONTENT, encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
return sdir
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_hashes(project_root: Path) -> dict[str, str]:
|
|
117
|
+
"""Load the stored ``{remote_path: blob_sha}`` mapping.
|
|
118
|
+
|
|
119
|
+
Returns an empty dict if the file doesn't exist or is corrupt.
|
|
120
|
+
"""
|
|
121
|
+
hp = hashes_path(project_root)
|
|
122
|
+
if not hp.exists():
|
|
123
|
+
return {}
|
|
124
|
+
try:
|
|
125
|
+
return json.loads(hp.read_text(encoding="utf-8"))
|
|
126
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
127
|
+
logger.warning("Failed to load hashes from %s: %s", hp, exc)
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def save_hashes(project_root: Path, hashes: dict[str, str]) -> None:
|
|
132
|
+
"""Persist the ``{remote_path: blob_sha}`` mapping."""
|
|
133
|
+
hp = hashes_path(project_root)
|
|
134
|
+
hp.write_text(json.dumps(hashes, indent=2) + "\n", encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_local_path(project_root: Path, local_path_str: str) -> Path:
|
|
138
|
+
"""Resolve a local_path from shared.json.
|
|
139
|
+
|
|
140
|
+
- Paths starting with ``/`` are relative to the project root.
|
|
141
|
+
- All other paths are relative to the shared directory.
|
|
142
|
+
"""
|
|
143
|
+
if local_path_str.startswith("/"):
|
|
144
|
+
return project_root / local_path_str.lstrip("/")
|
|
145
|
+
return shared_dir_path(project_root) / local_path_str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def extract_org_repo(url: str) -> str:
|
|
149
|
+
"""Extract ``org/repo`` from a Git URL (HTTPS or SSH).
|
|
150
|
+
|
|
151
|
+
Handles HTTPS, SSH shorthand (git@host:org/repo), and
|
|
152
|
+
full SSH URLs (ssh://git@host/org/repo). Strips trailing .git.
|
|
153
|
+
"""
|
|
154
|
+
url = url.rstrip("/")
|
|
155
|
+
if url.endswith(".git"):
|
|
156
|
+
url = url[:-4]
|
|
157
|
+
|
|
158
|
+
# SSH shorthand: git@host:org/repo
|
|
159
|
+
m = re.match(r"^[\w.-]+@[\w.-]+:(.*)", url)
|
|
160
|
+
if m:
|
|
161
|
+
return m.group(1)
|
|
162
|
+
|
|
163
|
+
# HTTPS / SSH-URL: extract last two path segments
|
|
164
|
+
m = re.search(r"/([\w._-]+/[\w._-]+)$", url)
|
|
165
|
+
if m:
|
|
166
|
+
return m.group(1)
|
|
167
|
+
|
|
168
|
+
raise ValueError(f"Cannot extract org/repo from URL: {url}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def detect_repo_identity(project_root: Path) -> str:
|
|
172
|
+
"""Detect the current repo's ``org/repo`` from its git remote origin."""
|
|
173
|
+
try:
|
|
174
|
+
from git import Repo
|
|
175
|
+
repo = Repo(project_root)
|
|
176
|
+
origin_url = repo.remotes.origin.url
|
|
177
|
+
return extract_org_repo(origin_url)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f"Cannot detect repo identity from {project_root}: {exc}"
|
|
181
|
+
) from exc
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def central_config_path(org_repo: str) -> str:
|
|
185
|
+
"""Return the path inside the source repo where the central config lives.
|
|
186
|
+
|
|
187
|
+
Example: ``org/repo`` -> ``.configs/org/repo.json``
|
|
188
|
+
"""
|
|
189
|
+
return f"{CENTRAL_CONFIG_DIR}/{org_repo}.json"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def parse_shared_files(data: dict) -> List[SharedFile]:
|
|
193
|
+
"""Parse the shared_files list from a config dict."""
|
|
194
|
+
shared_files: List[SharedFile] = []
|
|
195
|
+
for entry in data.get("shared_files", []):
|
|
196
|
+
shared_files.append(
|
|
197
|
+
SharedFile(
|
|
198
|
+
remote_path=entry["remote_path"],
|
|
199
|
+
local_path=entry["local_path"],
|
|
200
|
+
action=entry.get("action", "get"),
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return shared_files
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_config(project_root: Path) -> SharedConfig:
|
|
207
|
+
"""Read and parse shared.json from the project's shared directory."""
|
|
208
|
+
cfg_path = config_path(project_root)
|
|
209
|
+
if not cfg_path.exists():
|
|
210
|
+
raise FileNotFoundError(f"Config not found: {cfg_path}")
|
|
211
|
+
|
|
212
|
+
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
213
|
+
|
|
214
|
+
# Parse the source repo connection info
|
|
215
|
+
repo_data = data["source_repo"]
|
|
216
|
+
source_repo = SourceRepo(
|
|
217
|
+
url=repo_data["url"],
|
|
218
|
+
branch=repo_data.get("branch", "main"),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
mode = data.get("mode", "local")
|
|
222
|
+
shared_files = parse_shared_files(data)
|
|
223
|
+
|
|
224
|
+
return SharedConfig(
|
|
225
|
+
source_repo=source_repo,
|
|
226
|
+
shared_files=shared_files,
|
|
227
|
+
mode=mode,
|
|
228
|
+
)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Core get/push logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
from typing import Callable, List, Optional
|
|
12
|
+
|
|
13
|
+
import tlc_shared_docs.config as cfg
|
|
14
|
+
import tlc_shared_docs.git_ops as git_ops
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_config(
|
|
20
|
+
root: Path,
|
|
21
|
+
conf: cfg.SharedConfig,
|
|
22
|
+
central_url: Optional[str] = None,
|
|
23
|
+
_detect_identity: Callable[[Path], str] = cfg.detect_repo_identity,
|
|
24
|
+
_fetch_file: Callable[..., bytes | None] = git_ops.fetch_single_file,
|
|
25
|
+
) -> tuple[cfg.SharedConfig, List[str]]:
|
|
26
|
+
"""If *conf* is in central mode, fetch the config from the source repo.
|
|
27
|
+
|
|
28
|
+
Returns ``(resolved_config, messages)``.
|
|
29
|
+
"""
|
|
30
|
+
messages: List[str] = []
|
|
31
|
+
|
|
32
|
+
# CLI --central flag overrides the mode field in shared.json
|
|
33
|
+
source_url = central_url or (conf.source_repo.url if conf.mode == "central" else None)
|
|
34
|
+
if not source_url:
|
|
35
|
+
return conf, messages
|
|
36
|
+
|
|
37
|
+
# Detect this repo's org/repo identity from git remote
|
|
38
|
+
org_repo = _detect_identity(root)
|
|
39
|
+
config_path = cfg.central_config_path(org_repo)
|
|
40
|
+
|
|
41
|
+
# Build source repo settings, allowing CLI override of URL
|
|
42
|
+
source = cfg.SourceRepo(
|
|
43
|
+
url=source_url,
|
|
44
|
+
branch=conf.source_repo.branch if conf.source_repo.url == source_url else "main",
|
|
45
|
+
)
|
|
46
|
+
if central_url:
|
|
47
|
+
source = cfg.SourceRepo(url=central_url, branch=conf.source_repo.branch)
|
|
48
|
+
|
|
49
|
+
messages.append(f"Central mode: looking up {config_path} from {source.url}")
|
|
50
|
+
|
|
51
|
+
# Fetch the central config file from the shared docs repo
|
|
52
|
+
content = _fetch_file(source.url, source.branch, config_path)
|
|
53
|
+
if content is None:
|
|
54
|
+
raise FileNotFoundError(
|
|
55
|
+
f"Central config not found: {config_path} in {source.url} ({source.branch})"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
central_data = json.loads(content.decode("utf-8"))
|
|
59
|
+
central_files = cfg.parse_shared_files(central_data)
|
|
60
|
+
|
|
61
|
+
# Warn if local config also had shared_files -- central wins
|
|
62
|
+
if conf.shared_files:
|
|
63
|
+
messages.append(
|
|
64
|
+
"WARNING: Local shared.json contains shared_files entries, "
|
|
65
|
+
"but central mode is active. Central config takes precedence."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return cfg.SharedConfig(
|
|
69
|
+
source_repo=source,
|
|
70
|
+
shared_files=central_files,
|
|
71
|
+
mode="central",
|
|
72
|
+
), messages
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _expand_get_entries(
|
|
76
|
+
conf: cfg.SharedConfig,
|
|
77
|
+
_list_remote: Callable[..., List[str]] = git_ops.list_remote_files,
|
|
78
|
+
) -> tuple[List[cfg.SharedFile], List[str]]:
|
|
79
|
+
"""Expand glob entries in the get list into concrete SharedFile objects.
|
|
80
|
+
|
|
81
|
+
Returns ``(expanded_files, messages)``. Messages contain
|
|
82
|
+
warning info for glob resolution.
|
|
83
|
+
"""
|
|
84
|
+
plain: List[cfg.SharedFile] = []
|
|
85
|
+
glob_entries: List[cfg.SharedFile] = []
|
|
86
|
+
messages: List[str] = []
|
|
87
|
+
|
|
88
|
+
# Separate plain paths from glob patterns
|
|
89
|
+
for sf in conf.shared_files:
|
|
90
|
+
if sf.action != "get":
|
|
91
|
+
continue
|
|
92
|
+
if cfg.is_glob(sf.remote_path):
|
|
93
|
+
glob_entries.append(sf)
|
|
94
|
+
else:
|
|
95
|
+
plain.append(sf)
|
|
96
|
+
|
|
97
|
+
# Resolve each glob pattern against the remote tree
|
|
98
|
+
for sf in glob_entries:
|
|
99
|
+
matched = _list_remote(
|
|
100
|
+
conf.source_repo.url,
|
|
101
|
+
conf.source_repo.branch,
|
|
102
|
+
sf.remote_path,
|
|
103
|
+
)
|
|
104
|
+
if not matched:
|
|
105
|
+
messages.append(f"WARNING: No remote files matched pattern: {sf.remote_path}")
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Strip the non-glob prefix to preserve relative directory structure
|
|
109
|
+
prefix = cfg.glob_prefix(sf.remote_path)
|
|
110
|
+
|
|
111
|
+
for remote_path in matched:
|
|
112
|
+
# Build a local path that preserves directory structure under local_path
|
|
113
|
+
if prefix:
|
|
114
|
+
relative = remote_path[len(prefix):].lstrip("/")
|
|
115
|
+
else:
|
|
116
|
+
relative = remote_path
|
|
117
|
+
local_path = sf.local_path.rstrip("/") + "/" + relative
|
|
118
|
+
|
|
119
|
+
plain.append(cfg.SharedFile(
|
|
120
|
+
remote_path=remote_path,
|
|
121
|
+
local_path=local_path,
|
|
122
|
+
action="get",
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
messages.append(
|
|
126
|
+
f"Glob '{sf.remote_path}' matched {len(matched)} file(s)"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return plain, messages
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_files(
|
|
133
|
+
project_root: Optional[Path] = None,
|
|
134
|
+
dry_run: bool = False,
|
|
135
|
+
central_url: Optional[str] = None,
|
|
136
|
+
_get_shas: Callable[..., dict[str, str]] = git_ops.get_remote_blob_shas,
|
|
137
|
+
_sparse_checkout: Callable[..., tuple] = git_ops.sparse_checkout_files,
|
|
138
|
+
_read_clone: Callable[..., bytes] = git_ops.read_file_from_clone,
|
|
139
|
+
_cleanup: Callable[..., None] = git_ops.cleanup,
|
|
140
|
+
_detect_identity: Callable[[Path], str] = cfg.detect_repo_identity,
|
|
141
|
+
_fetch_file: Callable[..., bytes | None] = git_ops.fetch_single_file,
|
|
142
|
+
_list_remote: Callable[..., List[str]] = git_ops.list_remote_files,
|
|
143
|
+
) -> List[str]:
|
|
144
|
+
"""Pull shared files from the remote repo.
|
|
145
|
+
|
|
146
|
+
Returns a list of human-readable status messages.
|
|
147
|
+
Dependency parameters (prefixed with _) allow test injection.
|
|
148
|
+
"""
|
|
149
|
+
root = project_root or cfg.find_project_root()
|
|
150
|
+
cfg.ensure_shared_dir(root)
|
|
151
|
+
conf = cfg.load_config(root)
|
|
152
|
+
|
|
153
|
+
# Resolve central mode if applicable
|
|
154
|
+
conf, resolve_msgs = _resolve_config(
|
|
155
|
+
root, conf, central_url,
|
|
156
|
+
_detect_identity=_detect_identity,
|
|
157
|
+
_fetch_file=_fetch_file,
|
|
158
|
+
)
|
|
159
|
+
messages: List[str] = list(resolve_msgs)
|
|
160
|
+
|
|
161
|
+
# Expand any glob patterns into concrete file entries
|
|
162
|
+
files_to_get, expand_msgs = _expand_get_entries(conf, _list_remote=_list_remote)
|
|
163
|
+
messages.extend(expand_msgs)
|
|
164
|
+
if not files_to_get:
|
|
165
|
+
if not messages:
|
|
166
|
+
messages.append("No files with action=get found in shared.json")
|
|
167
|
+
return messages
|
|
168
|
+
|
|
169
|
+
remote_paths = [f.remote_path for f in files_to_get]
|
|
170
|
+
|
|
171
|
+
# Query remote blob SHAs to detect unchanged files (cheap, no blobs)
|
|
172
|
+
stored_hashes = cfg.load_hashes(root)
|
|
173
|
+
remote_shas = _get_shas(
|
|
174
|
+
conf.source_repo.url,
|
|
175
|
+
conf.source_repo.branch,
|
|
176
|
+
remote_paths,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Filter: only fetch files whose SHA changed or that don't exist locally
|
|
180
|
+
files_needed: List[cfg.SharedFile] = []
|
|
181
|
+
for sf in files_to_get:
|
|
182
|
+
remote_sha = remote_shas.get(sf.remote_path)
|
|
183
|
+
if remote_sha is None:
|
|
184
|
+
# File doesn't exist on remote -- will produce a warning later
|
|
185
|
+
files_needed.append(sf)
|
|
186
|
+
continue
|
|
187
|
+
stored_sha = stored_hashes.get(sf.remote_path)
|
|
188
|
+
local = cfg.resolve_local_path(root, sf.local_path)
|
|
189
|
+
if stored_sha == remote_sha and local.exists():
|
|
190
|
+
messages.append(f"SKIP (unchanged): {sf.remote_path}")
|
|
191
|
+
else:
|
|
192
|
+
files_needed.append(sf)
|
|
193
|
+
|
|
194
|
+
# Dry-run: show what would be fetched, then exit
|
|
195
|
+
if dry_run:
|
|
196
|
+
messages.extend(f"[dry-run] Would get: {sf.remote_path}" for sf in files_needed)
|
|
197
|
+
return messages
|
|
198
|
+
|
|
199
|
+
if not files_needed:
|
|
200
|
+
messages.append("All files up to date.")
|
|
201
|
+
return messages
|
|
202
|
+
|
|
203
|
+
# Sparse-checkout only the files that actually changed
|
|
204
|
+
needed_paths = [sf.remote_path for sf in files_needed]
|
|
205
|
+
clone_dir, _repo = _sparse_checkout(
|
|
206
|
+
conf.source_repo.url,
|
|
207
|
+
conf.source_repo.branch,
|
|
208
|
+
needed_paths,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
for sf in files_needed:
|
|
213
|
+
try:
|
|
214
|
+
content = _read_clone(clone_dir, sf.remote_path)
|
|
215
|
+
except FileNotFoundError:
|
|
216
|
+
messages.append(f"WARNING: Remote file not found: {sf.remote_path}")
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Write the fetched content to the local destination
|
|
220
|
+
local = cfg.resolve_local_path(root, sf.local_path)
|
|
221
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
local.write_bytes(content)
|
|
223
|
+
messages.append(f"OK: {sf.remote_path} -> {local.relative_to(root)}")
|
|
224
|
+
|
|
225
|
+
# Track the blob SHA so we can skip this file next time
|
|
226
|
+
sha = remote_shas.get(sf.remote_path)
|
|
227
|
+
if sha:
|
|
228
|
+
stored_hashes[sf.remote_path] = sha
|
|
229
|
+
finally:
|
|
230
|
+
_cleanup(clone_dir)
|
|
231
|
+
|
|
232
|
+
# Persist updated hashes for future runs
|
|
233
|
+
cfg.save_hashes(root, stored_hashes)
|
|
234
|
+
|
|
235
|
+
return messages
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def push_files(
|
|
239
|
+
project_root: Optional[Path] = None,
|
|
240
|
+
dry_run: bool = False,
|
|
241
|
+
force: bool = False,
|
|
242
|
+
central_url: Optional[str] = None,
|
|
243
|
+
_sparse_checkout: Callable[..., tuple] = git_ops.sparse_checkout_files,
|
|
244
|
+
_cleanup: Callable[..., None] = git_ops.cleanup,
|
|
245
|
+
_push: Callable[..., None] = git_ops.push_files,
|
|
246
|
+
_detect_identity: Callable[[Path], str] = cfg.detect_repo_identity,
|
|
247
|
+
_fetch_file: Callable[..., bytes | None] = git_ops.fetch_single_file,
|
|
248
|
+
) -> List[str]:
|
|
249
|
+
"""Push local shared files to the remote repo.
|
|
250
|
+
|
|
251
|
+
Returns a list of human-readable status messages.
|
|
252
|
+
Dependency parameters (prefixed with _) allow test injection.
|
|
253
|
+
"""
|
|
254
|
+
root = project_root or cfg.find_project_root()
|
|
255
|
+
conf = cfg.load_config(root)
|
|
256
|
+
|
|
257
|
+
# Resolve central mode if applicable
|
|
258
|
+
conf, resolve_msgs = _resolve_config(
|
|
259
|
+
root, conf, central_url,
|
|
260
|
+
_detect_identity=_detect_identity,
|
|
261
|
+
_fetch_file=_fetch_file,
|
|
262
|
+
)
|
|
263
|
+
messages: List[str] = list(resolve_msgs)
|
|
264
|
+
|
|
265
|
+
files_to_push = [f for f in conf.shared_files if f.action == "push"]
|
|
266
|
+
if not files_to_push:
|
|
267
|
+
messages.append("No files with action=push found in shared.json")
|
|
268
|
+
return messages
|
|
269
|
+
|
|
270
|
+
if dry_run:
|
|
271
|
+
messages.extend(
|
|
272
|
+
f"[dry-run] Would push: {f.local_path} -> {f.remote_path}"
|
|
273
|
+
for f in files_to_push
|
|
274
|
+
)
|
|
275
|
+
return messages
|
|
276
|
+
|
|
277
|
+
# Build the file map: remote_path -> local file bytes
|
|
278
|
+
file_map: dict[str, bytes] = {}
|
|
279
|
+
|
|
280
|
+
for sf in files_to_push:
|
|
281
|
+
local = cfg.resolve_local_path(root, sf.local_path)
|
|
282
|
+
if not local.exists():
|
|
283
|
+
messages.append(f"WARNING: Local file not found, skipping: {local}")
|
|
284
|
+
continue
|
|
285
|
+
file_map[sf.remote_path] = local.read_bytes()
|
|
286
|
+
|
|
287
|
+
if not file_map:
|
|
288
|
+
messages.append("No files to push (all missing locally).")
|
|
289
|
+
return messages
|
|
290
|
+
|
|
291
|
+
# Conflict check: verify remote files haven't changed since last pull
|
|
292
|
+
if not force:
|
|
293
|
+
remote_paths = list(file_map.keys())
|
|
294
|
+
clone_dir = None
|
|
295
|
+
try:
|
|
296
|
+
clone_dir, _repo = _sparse_checkout(
|
|
297
|
+
conf.source_repo.url,
|
|
298
|
+
conf.source_repo.branch,
|
|
299
|
+
remote_paths,
|
|
300
|
+
)
|
|
301
|
+
for remote_path, local_content in file_map.items():
|
|
302
|
+
remote_file = clone_dir / remote_path
|
|
303
|
+
if remote_file.exists():
|
|
304
|
+
remote_content = remote_file.read_bytes()
|
|
305
|
+
if remote_content != local_content:
|
|
306
|
+
messages.append(
|
|
307
|
+
f"CONFLICT: {remote_path} differs on remote. "
|
|
308
|
+
f"Use --force to overwrite."
|
|
309
|
+
)
|
|
310
|
+
if any("CONFLICT" in m for m in messages):
|
|
311
|
+
messages.append("Push aborted due to conflicts. Use --force to overwrite.")
|
|
312
|
+
return messages
|
|
313
|
+
except git_ops.GitError as exc:
|
|
314
|
+
# If we can't fetch to check conflicts, let the push attempt
|
|
315
|
+
# handle the error -- log so it's not silently swallowed
|
|
316
|
+
logger.warning("Could not check remote for conflicts: %s", exc)
|
|
317
|
+
finally:
|
|
318
|
+
if clone_dir:
|
|
319
|
+
_cleanup(clone_dir)
|
|
320
|
+
|
|
321
|
+
# Build the commit message from the local repo identity
|
|
322
|
+
repo_name = _repo_name_from_root(root)
|
|
323
|
+
branch_name = _current_branch(root)
|
|
324
|
+
commit_msg = f"Updated by {repo_name} on {branch_name}"
|
|
325
|
+
|
|
326
|
+
_push(
|
|
327
|
+
url=conf.source_repo.url,
|
|
328
|
+
branch=conf.source_repo.branch,
|
|
329
|
+
file_map=file_map,
|
|
330
|
+
commit_message=commit_msg,
|
|
331
|
+
force=force,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
for remote_path in file_map:
|
|
335
|
+
messages.append(f"OK: pushed {remote_path}")
|
|
336
|
+
|
|
337
|
+
return messages
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _repo_name_from_root(root: Path) -> str:
|
|
341
|
+
"""Best-effort repo name from the project root directory name."""
|
|
342
|
+
return root.name
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _current_branch(root: Path) -> str:
|
|
346
|
+
"""Best-effort current branch name of the local repo."""
|
|
347
|
+
try:
|
|
348
|
+
from git import Repo
|
|
349
|
+
repo = Repo(root)
|
|
350
|
+
return str(repo.active_branch)
|
|
351
|
+
except Exception as exc:
|
|
352
|
+
logger.warning("Could not detect current branch: %s", exc)
|
|
353
|
+
return "unknown"
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Low-level Git helpers using GitPython (and the ``git`` CLI it wraps)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from git import Repo, GitCommandError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitError(RuntimeError):
|
|
15
|
+
"""Raised when a git operation fails."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tmp_clone_dir() -> Path:
|
|
19
|
+
"""Return a fresh temporary directory for cloning."""
|
|
20
|
+
return Path(tempfile.mkdtemp(prefix="tlc_shared_docs_"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def list_remote_files(
|
|
24
|
+
url: str,
|
|
25
|
+
branch: str,
|
|
26
|
+
pattern: str,
|
|
27
|
+
) -> List[str]:
|
|
28
|
+
"""Return remote file paths matching a glob *pattern*.
|
|
29
|
+
|
|
30
|
+
Uses a treeless clone (``--filter=tree:0``) so only the tree metadata
|
|
31
|
+
is fetched -- no file blobs are downloaded.
|
|
32
|
+
"""
|
|
33
|
+
clone_dir = _tmp_clone_dir()
|
|
34
|
+
try:
|
|
35
|
+
repo = Repo.init(clone_dir)
|
|
36
|
+
repo.git.remote("add", "origin", url)
|
|
37
|
+
|
|
38
|
+
# Treeless fetch: downloads tree objects but no blobs
|
|
39
|
+
repo.git.fetch("origin", branch, depth=1, filter="tree:0")
|
|
40
|
+
|
|
41
|
+
# List every file path in the tree
|
|
42
|
+
output = repo.git.ls_tree("-r", "--name-only", f"origin/{branch}")
|
|
43
|
+
all_files = output.splitlines() if output else []
|
|
44
|
+
|
|
45
|
+
# Filter with fnmatch (supports *, ?, [seq], **)
|
|
46
|
+
matched = [f for f in all_files if fnmatch.fnmatch(f, pattern)]
|
|
47
|
+
return matched
|
|
48
|
+
except GitCommandError as exc:
|
|
49
|
+
raise GitError(f"Failed to list files from {url}: {exc}") from exc
|
|
50
|
+
finally:
|
|
51
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_remote_blob_shas(
|
|
55
|
+
url: str,
|
|
56
|
+
branch: str,
|
|
57
|
+
file_paths: List[str],
|
|
58
|
+
) -> dict[str, str]:
|
|
59
|
+
"""Return ``{file_path: blob_sha}`` for each of *file_paths* that exists
|
|
60
|
+
on *branch* of *url*.
|
|
61
|
+
|
|
62
|
+
Uses a treeless fetch so no file content is downloaded -- only
|
|
63
|
+
tree metadata needed to read the blob SHA per path.
|
|
64
|
+
"""
|
|
65
|
+
clone_dir = _tmp_clone_dir()
|
|
66
|
+
try:
|
|
67
|
+
repo = Repo.init(clone_dir)
|
|
68
|
+
repo.git.remote("add", "origin", url)
|
|
69
|
+
repo.git.fetch("origin", branch, depth=1, filter="tree:0")
|
|
70
|
+
|
|
71
|
+
# Parse full ls-tree output: "<mode> <type> <sha>\t<path>"
|
|
72
|
+
output = repo.git.ls_tree("-r", f"origin/{branch}")
|
|
73
|
+
if not output:
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
sha_map: dict[str, str] = {}
|
|
77
|
+
wanted = set(file_paths)
|
|
78
|
+
for line in output.splitlines():
|
|
79
|
+
parts = line.split(None, 3) # mode, type, sha, path
|
|
80
|
+
if len(parts) == 4:
|
|
81
|
+
path = parts[3]
|
|
82
|
+
if path in wanted:
|
|
83
|
+
sha_map[path] = parts[2]
|
|
84
|
+
return sha_map
|
|
85
|
+
except GitCommandError as exc:
|
|
86
|
+
raise GitError(f"Failed to get blob SHAs from {url}: {exc}") from exc
|
|
87
|
+
finally:
|
|
88
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sparse_checkout_files(
|
|
92
|
+
url: str,
|
|
93
|
+
branch: str,
|
|
94
|
+
file_paths: List[str],
|
|
95
|
+
) -> tuple[Path, Repo]:
|
|
96
|
+
"""Clone *url* at *branch* with a **sparse checkout** containing only
|
|
97
|
+
*file_paths*. Returns ``(clone_dir, Repo)``.
|
|
98
|
+
|
|
99
|
+
Depth=1 avoids fetching full history -- we only need latest content.
|
|
100
|
+
"""
|
|
101
|
+
clone_dir = _tmp_clone_dir()
|
|
102
|
+
try:
|
|
103
|
+
# Initialise an empty repo and configure sparse-checkout
|
|
104
|
+
repo = Repo.init(clone_dir)
|
|
105
|
+
repo.git.remote("add", "origin", url)
|
|
106
|
+
repo.git.config("core.sparseCheckout", "true")
|
|
107
|
+
|
|
108
|
+
# Write the sparse-checkout patterns so only requested files appear
|
|
109
|
+
sparse_file = Path(repo.git_dir) / "info" / "sparse-checkout"
|
|
110
|
+
sparse_file.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
sparse_file.write_text("\n".join(file_paths) + "\n", encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
# Fetch only the requested branch (shallow, single-branch)
|
|
114
|
+
repo.git.fetch("origin", branch, depth=1)
|
|
115
|
+
repo.git.checkout(f"origin/{branch}", b=branch)
|
|
116
|
+
|
|
117
|
+
return clone_dir, repo
|
|
118
|
+
except GitCommandError as exc:
|
|
119
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
120
|
+
raise GitError(f"Failed to sparse-checkout from {url}: {exc}") from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_file_from_clone(clone_dir: Path, remote_path: str) -> bytes:
|
|
124
|
+
"""Read a single file out of a sparse clone."""
|
|
125
|
+
target = clone_dir / remote_path
|
|
126
|
+
if not target.exists():
|
|
127
|
+
raise FileNotFoundError(f"File not found in clone: {remote_path}")
|
|
128
|
+
return target.read_bytes()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def push_files(
|
|
132
|
+
url: str,
|
|
133
|
+
branch: str,
|
|
134
|
+
file_map: dict[str, bytes],
|
|
135
|
+
commit_message: str,
|
|
136
|
+
force: bool = False,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Clone *url*, write *file_map* ``{remote_path: content}``, commit, and
|
|
139
|
+
push to *branch*.
|
|
140
|
+
|
|
141
|
+
If *force* is ``True`` the push uses ``--force``.
|
|
142
|
+
"""
|
|
143
|
+
clone_dir = _tmp_clone_dir()
|
|
144
|
+
try:
|
|
145
|
+
# Shallow clone with the target branch checked out
|
|
146
|
+
repo = Repo.init(clone_dir)
|
|
147
|
+
repo.git.remote("add", "origin", url)
|
|
148
|
+
repo.git.fetch("origin", branch, depth=1)
|
|
149
|
+
repo.git.checkout(f"origin/{branch}", b=branch)
|
|
150
|
+
|
|
151
|
+
# Write each file and stage it for commit
|
|
152
|
+
changed = False
|
|
153
|
+
for remote_path, content in file_map.items():
|
|
154
|
+
dest = clone_dir / remote_path
|
|
155
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
|
|
157
|
+
# Skip files that already match to avoid empty commits
|
|
158
|
+
if dest.exists() and dest.read_bytes() == content:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
dest.write_bytes(content)
|
|
162
|
+
repo.index.add([remote_path])
|
|
163
|
+
changed = True
|
|
164
|
+
|
|
165
|
+
if not changed:
|
|
166
|
+
return # nothing to push
|
|
167
|
+
|
|
168
|
+
repo.index.commit(commit_message)
|
|
169
|
+
|
|
170
|
+
push_args = ["origin", branch]
|
|
171
|
+
if force:
|
|
172
|
+
push_args.insert(0, "--force")
|
|
173
|
+
repo.git.push(*push_args)
|
|
174
|
+
except GitCommandError as exc:
|
|
175
|
+
raise GitError(f"Failed to push to {url}: {exc}") from exc
|
|
176
|
+
finally:
|
|
177
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def fetch_single_file(url: str, branch: str, file_path: str) -> bytes | None:
|
|
181
|
+
"""Fetch a single file from a remote repo via sparse checkout.
|
|
182
|
+
|
|
183
|
+
Returns the file contents, or ``None`` if the file does not exist.
|
|
184
|
+
"""
|
|
185
|
+
clone_dir = _tmp_clone_dir()
|
|
186
|
+
try:
|
|
187
|
+
clone_dir, _repo = sparse_checkout_files(url, branch, [file_path])
|
|
188
|
+
target = clone_dir / file_path
|
|
189
|
+
if not target.exists():
|
|
190
|
+
return None
|
|
191
|
+
return target.read_bytes()
|
|
192
|
+
except GitError:
|
|
193
|
+
raise
|
|
194
|
+
finally:
|
|
195
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cleanup(clone_dir: Path) -> None:
|
|
199
|
+
"""Remove a temporary clone directory."""
|
|
200
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|