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.
@@ -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,3 @@
1
+ """tlc-shared-docs: Share documentation files between Git repositories."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)