contextlake 2.1.0__py3-none-any.whl
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.
- contextlake/__init__.py +3 -0
- contextlake/__main__.py +6 -0
- contextlake/cli.py +333 -0
- contextlake/config.py +120 -0
- contextlake/core.py +773 -0
- contextlake/kb/__init__.py +15 -0
- contextlake/kb/commands.py +698 -0
- contextlake/kb/config.py +138 -0
- contextlake/kb/connectors/__init__.py +7 -0
- contextlake/kb/connectors/atlassian.py +175 -0
- contextlake/kb/connectors/common.py +42 -0
- contextlake/kb/connectors/figma.py +124 -0
- contextlake/kb/connectors/gitlab.py +86 -0
- contextlake/kb/connectors/orchestrate.py +143 -0
- contextlake/kb/embeddings/__init__.py +9 -0
- contextlake/kb/embeddings/base.py +48 -0
- contextlake/kb/embeddings/hybrid.py +79 -0
- contextlake/kb/embeddings/index.py +46 -0
- contextlake/kb/embeddings/ollama.py +42 -0
- contextlake/kb/embeddings/openai.py +54 -0
- contextlake/kb/embeddings/store.py +200 -0
- contextlake/kb/ids.py +40 -0
- contextlake/kb/llm/__init__.py +9 -0
- contextlake/kb/llm/base.py +44 -0
- contextlake/kb/llm/ollama.py +39 -0
- contextlake/kb/llm/openai.py +53 -0
- contextlake/kb/manifest.py +101 -0
- contextlake/kb/mcp_client.py +63 -0
- contextlake/kb/model.py +71 -0
- contextlake/kb/parse.py +329 -0
- contextlake/kb/references.py +66 -0
- contextlake/kb/security.py +34 -0
- contextlake/kb/server.py +231 -0
- contextlake/kb/state.py +40 -0
- contextlake/kb/steer/__init__.py +7 -0
- contextlake/kb/steer/generate.py +161 -0
- contextlake/kb/steer/skills.py +113 -0
- contextlake/kb/store/__init__.py +5 -0
- contextlake/kb/store/base.py +81 -0
- contextlake/kb/store/shards.py +97 -0
- contextlake/kb/store/sqlite_store.py +265 -0
- contextlake/kb/wiki/__init__.py +4 -0
- contextlake/kb/wiki/council.py +60 -0
- contextlake/kb/wiki/generate.py +86 -0
- contextlake/logging_setup.py +119 -0
- contextlake/safety.py +82 -0
- contextlake/style.py +147 -0
- contextlake-2.1.0.dist-info/METADATA +240 -0
- contextlake-2.1.0.dist-info/RECORD +53 -0
- contextlake-2.1.0.dist-info/WHEEL +5 -0
- contextlake-2.1.0.dist-info/entry_points.txt +3 -0
- contextlake-2.1.0.dist-info/licenses/LICENSE +21 -0
- contextlake-2.1.0.dist-info/top_level.txt +1 -0
contextlake/__init__.py
ADDED
contextlake/__main__.py
ADDED
contextlake/cli.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GitLab Workspace Synchronization CLI Tool.
|
|
3
|
+
|
|
4
|
+
Keeps a local workspace mirrored with the GitLab repositories you can access:
|
|
5
|
+
clones what is missing, updates existing clones, and moves each repo onto its
|
|
6
|
+
most active development branch -- while protecting any local working branches.
|
|
7
|
+
|
|
8
|
+
Entry points (all equivalent):
|
|
9
|
+
contextlake <command> # installed console script
|
|
10
|
+
python -m contextlake <command>
|
|
11
|
+
python3 contextlake.py <command> # bare script, no install
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .config import DEFAULT_CONFIG, expand_path, get_cache_paths, load_config
|
|
19
|
+
from .core import (
|
|
20
|
+
clone_missing_repos,
|
|
21
|
+
fetch_gitlab_projects,
|
|
22
|
+
show_status,
|
|
23
|
+
switch_repository_branches,
|
|
24
|
+
update_repositories,
|
|
25
|
+
verify_structure,
|
|
26
|
+
)
|
|
27
|
+
from .logging_setup import log, setup_logging
|
|
28
|
+
|
|
29
|
+
# Boolean flags backed by paired --x / --no-x switches. They must default to
|
|
30
|
+
# None so we can tell "user passed a flag" from "user said nothing" -- otherwise
|
|
31
|
+
# the store_true default (False) silently overrides the config file every run.
|
|
32
|
+
_TRISTATE_FLAGS = (
|
|
33
|
+
"clean_corrupted",
|
|
34
|
+
"adaptive_workers",
|
|
35
|
+
"protect_working_branches",
|
|
36
|
+
"require_clean_workspace",
|
|
37
|
+
"auto_stash",
|
|
38
|
+
"dry_run",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Scalar CLI options that map 1:1 onto config keys.
|
|
42
|
+
_SCALAR_FLAGS = (
|
|
43
|
+
"max_retries",
|
|
44
|
+
"backoff_initial",
|
|
45
|
+
"backoff_max",
|
|
46
|
+
"min_workers",
|
|
47
|
+
"error_threshold",
|
|
48
|
+
"safe_branches",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser():
|
|
53
|
+
"""Build the argument parser. Kept separate from main() so it is testable."""
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="contextlake",
|
|
56
|
+
description="GitLab Workspace Synchronization CLI Tool",
|
|
57
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
58
|
+
epilog="""
|
|
59
|
+
Examples:
|
|
60
|
+
contextlake sync # Run full synchronization
|
|
61
|
+
contextlake status # Show status (read-only)
|
|
62
|
+
contextlake --dry-run sync # Show what sync would do, change nothing
|
|
63
|
+
""",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"command",
|
|
68
|
+
choices=[
|
|
69
|
+
"fetch", "clone", "update", "branches", "verify", "sync", "status",
|
|
70
|
+
# one-command turnkey setup (sync + knowledge layer + steering)
|
|
71
|
+
"bootstrap",
|
|
72
|
+
# knowledge layer (optional [kb] extra)
|
|
73
|
+
"index", "connect", "embed", "lint", "wiki", "steer",
|
|
74
|
+
"serve", "query", "doctor",
|
|
75
|
+
],
|
|
76
|
+
help="Command to execute",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument("args", nargs="*", help="Positional arguments (e.g. query text)")
|
|
79
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
80
|
+
parser.add_argument("--work-dir", help="Working directory (overrides config file)")
|
|
81
|
+
parser.add_argument("--group", help="GitLab group (overrides config file)")
|
|
82
|
+
parser.add_argument("--config", help="Path to config file (overrides default search paths)")
|
|
83
|
+
|
|
84
|
+
# Knowledge-layer options (used by index/serve/query/doctor)
|
|
85
|
+
kb = parser.add_argument_group("knowledge layer")
|
|
86
|
+
kb.add_argument("--source", help="index: a repo directory or a graph shard JSON")
|
|
87
|
+
kb.add_argument("--workspace", help="index: index every git repo under this directory")
|
|
88
|
+
kb.add_argument("--force", action="store_true",
|
|
89
|
+
help="index: re-index every repo; steer: overwrite non-managed files")
|
|
90
|
+
kb.add_argument("--out", help="steer: directory to write steering files into (default: cwd)")
|
|
91
|
+
kb.add_argument("--kb-config", dest="kb_config",
|
|
92
|
+
help="bootstrap: knowledge-layer config (kb.toml), separate from the sync INI")
|
|
93
|
+
kb.add_argument("--no-sync", dest="no_sync", action="store_true",
|
|
94
|
+
help="bootstrap: skip the GitLab mirror step (index the workspace as-is)")
|
|
95
|
+
kb.add_argument("--no-connect", dest="no_connect", action="store_true",
|
|
96
|
+
help="bootstrap: skip the connectors step")
|
|
97
|
+
kb.add_argument("--no-embed", dest="no_embed", action="store_true",
|
|
98
|
+
help="bootstrap: skip the embeddings step")
|
|
99
|
+
kb.add_argument("--no-wiki", dest="no_wiki", action="store_true",
|
|
100
|
+
help="bootstrap: skip the wiki-generation step")
|
|
101
|
+
kb.add_argument("--watch", action="store_true",
|
|
102
|
+
help="index --workspace: keep re-indexing on an interval (Ctrl-C to stop)")
|
|
103
|
+
kb.add_argument("--interval", type=int,
|
|
104
|
+
help="index --watch: seconds between passes (default 60)")
|
|
105
|
+
kb.add_argument("--transport", choices=["stdio", "http"], help="serve: MCP transport")
|
|
106
|
+
kb.add_argument("--host", help="serve: bind host (http transport)")
|
|
107
|
+
kb.add_argument("--port", type=int, help="serve: bind port (http transport)")
|
|
108
|
+
kb.add_argument("--kind", help="query: filter by node kind")
|
|
109
|
+
kb.add_argument("--repo", help="query: filter by repo")
|
|
110
|
+
kb.add_argument("--limit", type=int, help="query: max results")
|
|
111
|
+
kb.add_argument("--as-of", dest="as_of",
|
|
112
|
+
help="query: search a repo's snapshot at this indexed commit (needs --repo)")
|
|
113
|
+
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--dry-run", action="store_true", dest="dry_run",
|
|
116
|
+
help="Show what would happen without cloning, updating, or switching branches",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Logging / verbosity
|
|
120
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose (debug) output")
|
|
121
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="Only warnings and errors")
|
|
122
|
+
parser.add_argument("--log-file", help="Append a full timestamped log to this file")
|
|
123
|
+
|
|
124
|
+
# Clone / corruption handling
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--clean-corrupted", action="store_true", dest="clean_corrupted",
|
|
127
|
+
help="Remove corrupted/incomplete directories before cloning (default: true)",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--no-clean-corrupted", action="store_false", dest="clean_corrupted",
|
|
131
|
+
help="Do not remove corrupted/incomplete directories (fail instead)",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Retry / backoff
|
|
135
|
+
parser.add_argument("--max-retries", type=int, help="Max retry attempts for failed operations")
|
|
136
|
+
parser.add_argument("--backoff-initial", type=float, help="Initial backoff time in seconds")
|
|
137
|
+
parser.add_argument("--backoff-max", type=float, help="Maximum backoff time in seconds")
|
|
138
|
+
|
|
139
|
+
# Adaptive parallelism
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--adaptive-workers", action="store_true", dest="adaptive_workers",
|
|
142
|
+
help="Enable adaptive worker pool (default: true)",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--no-adaptive-workers", action="store_false", dest="adaptive_workers",
|
|
146
|
+
help="Disable adaptive worker pool (use static max_workers)",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument("--min-workers", type=int, help="Minimum workers for the adaptive pool")
|
|
149
|
+
parser.add_argument("--error-threshold", type=float, help="Error rate threshold (0.0-1.0)")
|
|
150
|
+
|
|
151
|
+
# Branch safety
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--protect-working-branches", action="store_true", dest="protect_working_branches",
|
|
154
|
+
help="Enable branch protection (default: true)",
|
|
155
|
+
)
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"--no-protect-working-branches", action="store_false", dest="protect_working_branches",
|
|
158
|
+
help="Disable branch protection (allow operations on any branch)",
|
|
159
|
+
)
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--safe-branches",
|
|
162
|
+
help="Comma-separated safe branches (default: main,master,develop,development)",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
"--require-clean-workspace", action="store_true", dest="require_clean_workspace",
|
|
166
|
+
help="Require clean workspace before operations (default: true)",
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"--no-require-clean-workspace", action="store_false", dest="require_clean_workspace",
|
|
170
|
+
help="Allow operations with uncommitted changes",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--auto-stash", action="store_true", dest="auto_stash",
|
|
174
|
+
help="Automatically stash changes before operations (default: false)",
|
|
175
|
+
)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--no-auto-stash", action="store_false", dest="auto_stash",
|
|
178
|
+
help="Disable automatic stashing",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Tri-state booleans: unset on the command line -> None -> config wins.
|
|
182
|
+
parser.set_defaults(**{name: None for name in _TRISTATE_FLAGS})
|
|
183
|
+
return parser
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def apply_cli_overrides(args, config):
|
|
187
|
+
"""Overlay CLI arguments onto a loaded config dict. Returns the same dict.
|
|
188
|
+
|
|
189
|
+
Only values the user actually supplied override the config file; everything
|
|
190
|
+
else is left untouched so config-file (and built-in default) values survive.
|
|
191
|
+
"""
|
|
192
|
+
for name in _TRISTATE_FLAGS:
|
|
193
|
+
value = getattr(args, name, None)
|
|
194
|
+
if value is not None:
|
|
195
|
+
config[name] = "true" if value else "false"
|
|
196
|
+
|
|
197
|
+
for name in _SCALAR_FLAGS:
|
|
198
|
+
value = getattr(args, name, None)
|
|
199
|
+
if value is not None:
|
|
200
|
+
config[name] = str(value)
|
|
201
|
+
|
|
202
|
+
return config
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _bootstrap(args, config, work_dir, gitlab_group):
|
|
206
|
+
"""One-command turnkey setup: mirror repos, build the knowledge layer, and write
|
|
207
|
+
editor steering. Optional/unconfigured stages are skipped; a failing stage warns
|
|
208
|
+
but never aborts the rest."""
|
|
209
|
+
import copy
|
|
210
|
+
|
|
211
|
+
from . import style
|
|
212
|
+
|
|
213
|
+
def _stage(title):
|
|
214
|
+
log("")
|
|
215
|
+
log(style.bold(style.cyan(f"▶ {title}")))
|
|
216
|
+
|
|
217
|
+
if not getattr(args, "no_sync", False):
|
|
218
|
+
_stage("Mirror repositories from GitLab")
|
|
219
|
+
fetch_gitlab_projects(gitlab_group, config)
|
|
220
|
+
clone_missing_repos(work_dir, config, gitlab_group)
|
|
221
|
+
update_repositories(work_dir, config)
|
|
222
|
+
switch_repository_branches(work_dir, config, gitlab_group)
|
|
223
|
+
verify_structure(work_dir, config, gitlab_group)
|
|
224
|
+
else:
|
|
225
|
+
log("Skipping the GitLab mirror step (--no-sync)")
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
from .kb import commands as kb
|
|
229
|
+
except ImportError as e:
|
|
230
|
+
log(f"Knowledge layer not installed — skipping index/connect/embed/wiki/steer. "
|
|
231
|
+
f"Install it with: pip install 'contextlake[kb]' ({e})")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
# kb stages run against the workspace and the *kb* config (kb.toml), which is
|
|
235
|
+
# distinct from the core sync INI passed as --config.
|
|
236
|
+
kb_args = copy.copy(args)
|
|
237
|
+
kb_args.config = getattr(args, "kb_config", None)
|
|
238
|
+
kb_args.workspace = work_dir
|
|
239
|
+
kb_args.source = None
|
|
240
|
+
kb_args.out = work_dir
|
|
241
|
+
|
|
242
|
+
stages = [("Index the code graph", kb.cmd_index)]
|
|
243
|
+
if not getattr(args, "no_connect", False):
|
|
244
|
+
stages.append(("Connect knowledge sources", kb.cmd_connect))
|
|
245
|
+
if not getattr(args, "no_embed", False):
|
|
246
|
+
stages.append(("Build semantic vectors", kb.cmd_embed))
|
|
247
|
+
if not getattr(args, "no_wiki", False):
|
|
248
|
+
stages.append(("Generate the curated wiki", kb.cmd_wiki))
|
|
249
|
+
stages.append(("Write editor steering (.mcp.json, AGENTS.md, …)", kb.cmd_steer))
|
|
250
|
+
|
|
251
|
+
for title, fn in stages:
|
|
252
|
+
_stage(title)
|
|
253
|
+
try:
|
|
254
|
+
fn(kb_args)
|
|
255
|
+
except Exception as e: # noqa: BLE001 - one stage must not abort bootstrap
|
|
256
|
+
log(f" {style.warn(title + ' failed')} — {e}")
|
|
257
|
+
|
|
258
|
+
log("")
|
|
259
|
+
serve = "contextlake serve" + (f" --config {kb_args.config}" if kb_args.config else "")
|
|
260
|
+
log(style.ok(f"Bootstrap complete — workspace ready at {work_dir}."))
|
|
261
|
+
log(f" Editors are wired (.mcp.json + steering). Start the knowledge server: {serve}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(argv=None):
|
|
265
|
+
parser = build_parser()
|
|
266
|
+
args = parser.parse_args(argv)
|
|
267
|
+
|
|
268
|
+
setup_logging(verbose=args.verbose, quiet=args.quiet, log_file=args.log_file)
|
|
269
|
+
|
|
270
|
+
# Knowledge-layer verbs are handled by the optional kb subsystem and don't
|
|
271
|
+
# need the sync config/preamble. Imported lazily so the core tool runs
|
|
272
|
+
# without the [kb] extra.
|
|
273
|
+
if args.command in ("index", "connect", "embed", "lint", "wiki", "steer",
|
|
274
|
+
"serve", "query", "doctor"):
|
|
275
|
+
try:
|
|
276
|
+
from .kb import commands as kb_commands
|
|
277
|
+
except ImportError as e:
|
|
278
|
+
log(f"The '{args.command}' command needs the knowledge-layer extra: "
|
|
279
|
+
f"pip install 'contextlake[kb]' ({e})")
|
|
280
|
+
sys.exit(1)
|
|
281
|
+
sys.exit(kb_commands.dispatch(args.command, args))
|
|
282
|
+
|
|
283
|
+
# Load configuration (honouring an explicit --config path if given), then
|
|
284
|
+
# overlay any CLI overrides on top.
|
|
285
|
+
config = load_config(args.config)
|
|
286
|
+
config = apply_cli_overrides(args, config)
|
|
287
|
+
|
|
288
|
+
work_dir = expand_path(args.work_dir) if args.work_dir else config.get(
|
|
289
|
+
"work_dir", DEFAULT_CONFIG["work_dir"]
|
|
290
|
+
)
|
|
291
|
+
gitlab_group = args.group or config.get("gitlab_group", DEFAULT_CONFIG["gitlab_group"])
|
|
292
|
+
|
|
293
|
+
log(f"Working directory: {work_dir}")
|
|
294
|
+
log(f"GitLab group: {gitlab_group}")
|
|
295
|
+
cache_file, _ = get_cache_paths(config)
|
|
296
|
+
log(f"Cache file: {cache_file}")
|
|
297
|
+
if config.get("dry_run", "false").lower() == "true":
|
|
298
|
+
log("DRY RUN: no repositories will be cloned, updated, or switched")
|
|
299
|
+
log("")
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
if args.command == "fetch":
|
|
303
|
+
fetch_gitlab_projects(gitlab_group, config)
|
|
304
|
+
elif args.command == "clone":
|
|
305
|
+
clone_missing_repos(work_dir, config, gitlab_group)
|
|
306
|
+
elif args.command == "update":
|
|
307
|
+
update_repositories(work_dir, config)
|
|
308
|
+
elif args.command == "branches":
|
|
309
|
+
switch_repository_branches(work_dir, config, gitlab_group)
|
|
310
|
+
elif args.command == "verify":
|
|
311
|
+
verify_structure(work_dir, config, gitlab_group)
|
|
312
|
+
elif args.command == "sync":
|
|
313
|
+
log("Starting full synchronization...")
|
|
314
|
+
fetch_gitlab_projects(gitlab_group, config)
|
|
315
|
+
clone_missing_repos(work_dir, config, gitlab_group)
|
|
316
|
+
update_repositories(work_dir, config)
|
|
317
|
+
switch_repository_branches(work_dir, config, gitlab_group)
|
|
318
|
+
verify_structure(work_dir, config, gitlab_group)
|
|
319
|
+
log("Full synchronization complete!")
|
|
320
|
+
elif args.command == "status":
|
|
321
|
+
show_status(work_dir, config, gitlab_group)
|
|
322
|
+
elif args.command == "bootstrap":
|
|
323
|
+
_bootstrap(args, config, work_dir, gitlab_group)
|
|
324
|
+
except KeyboardInterrupt:
|
|
325
|
+
log("Operation cancelled by user")
|
|
326
|
+
sys.exit(130)
|
|
327
|
+
except Exception as e: # noqa: BLE001 - top-level guard reports and exits
|
|
328
|
+
log(f"Error: {e}")
|
|
329
|
+
sys.exit(1)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
main()
|
contextlake/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loading for contextlake
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from .logging_setup import log
|
|
9
|
+
|
|
10
|
+
# Configuration file paths. The current (contextlake) files take precedence, but
|
|
11
|
+
# the former gitlab-sync files are still read so existing setups keep working
|
|
12
|
+
# without any change after the rename.
|
|
13
|
+
CONFIG_FILE = os.path.expanduser('~/.contextlake.ini')
|
|
14
|
+
LOCAL_CONFIG_FILE = '.contextlake.ini'
|
|
15
|
+
LEGACY_CONFIG_FILE = os.path.expanduser('~/.gitlab_sync.ini')
|
|
16
|
+
LEGACY_LOCAL_CONFIG_FILE = '.gitlab_sync.ini'
|
|
17
|
+
|
|
18
|
+
# INI section names, low-to-high precedence: the current section wins if a file
|
|
19
|
+
# happens to carry both.
|
|
20
|
+
SECTIONS = ('gitlab_sync', 'contextlake')
|
|
21
|
+
|
|
22
|
+
# Config values that name a filesystem location and so must have ~ and $VARS
|
|
23
|
+
# expanded (the INI/CLI layers store them verbatim, unlike DEFAULT_CONFIG).
|
|
24
|
+
PATH_KEYS = ('work_dir', 'cache_dir')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def expand_path(value):
|
|
28
|
+
"""Expand ~ and environment variables in a path-like config value."""
|
|
29
|
+
return os.path.expanduser(os.path.expandvars(value)) if value else value
|
|
30
|
+
|
|
31
|
+
# Default Configuration
|
|
32
|
+
DEFAULT_CONFIG = {
|
|
33
|
+
'work_dir': os.path.expanduser('~/work'),
|
|
34
|
+
'gitlab_group': 'your-gitlab-group',
|
|
35
|
+
'cache_dir': '/tmp',
|
|
36
|
+
'cache_file': 'gitlab_projects.txt',
|
|
37
|
+
'cache_json': 'gitlab_projects.json',
|
|
38
|
+
'clone_timeout': '300',
|
|
39
|
+
'fetch_timeout': '60',
|
|
40
|
+
'branch_timeout': '30',
|
|
41
|
+
'pull_timeout': '60',
|
|
42
|
+
'max_workers': '8',
|
|
43
|
+
'clone_method': 'auto', # auto -> prefer glab (uses its auth), else git over HTTPS
|
|
44
|
+
'branch_strategy': 'hybrid', # most-active selection: commits | recency | hybrid
|
|
45
|
+
'clean_corrupted': 'true',
|
|
46
|
+
'max_retries': '3',
|
|
47
|
+
'backoff_initial': '1',
|
|
48
|
+
'backoff_max': '30',
|
|
49
|
+
'adaptive_workers': 'true',
|
|
50
|
+
'min_workers': '2',
|
|
51
|
+
'error_threshold': '0.5',
|
|
52
|
+
'protect_working_branches': 'true',
|
|
53
|
+
'safe_branches': 'main,master,develop,development',
|
|
54
|
+
'require_clean_workspace': 'true',
|
|
55
|
+
'auto_stash': 'false'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _merge(config, path):
|
|
60
|
+
"""Merge an INI file's config section into config, if present.
|
|
61
|
+
|
|
62
|
+
Accepts either the current ``[contextlake]`` section or the legacy
|
|
63
|
+
``[gitlab_sync]`` one (current wins if both are present in one file).
|
|
64
|
+
"""
|
|
65
|
+
if not path or not os.path.exists(path):
|
|
66
|
+
return
|
|
67
|
+
parser = configparser.ConfigParser()
|
|
68
|
+
parser.read(path)
|
|
69
|
+
for section in SECTIONS:
|
|
70
|
+
if section in parser:
|
|
71
|
+
config.update(parser[section])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(config_path=None):
|
|
75
|
+
"""Load configuration with precedence: explicit --config > local > global > defaults.
|
|
76
|
+
|
|
77
|
+
Sources are merged from lowest to highest precedence so the later (more
|
|
78
|
+
specific) source wins on conflicting keys. The legacy gitlab-sync files are
|
|
79
|
+
read just below their contextlake counterparts, so an existing setup keeps
|
|
80
|
+
working while a new contextlake file (if present) takes precedence.
|
|
81
|
+
"""
|
|
82
|
+
config = DEFAULT_CONFIG.copy()
|
|
83
|
+
_merge(config, LEGACY_CONFIG_FILE) # legacy global (~/.gitlab_sync.ini)
|
|
84
|
+
_merge(config, CONFIG_FILE) # global (~/.contextlake.ini)
|
|
85
|
+
_merge(config, LEGACY_LOCAL_CONFIG_FILE) # legacy local workspace config
|
|
86
|
+
_merge(config, LOCAL_CONFIG_FILE) # local workspace config
|
|
87
|
+
_merge(config, config_path) # explicit --config path
|
|
88
|
+
|
|
89
|
+
# INI/CLI values are stored verbatim, so a `work_dir = ~/repos` would
|
|
90
|
+
# otherwise be treated as a literal "~" directory. Expand here.
|
|
91
|
+
for key in PATH_KEYS:
|
|
92
|
+
if key in config:
|
|
93
|
+
config[key] = expand_path(config[key])
|
|
94
|
+
|
|
95
|
+
if config.get('gitlab_group') == DEFAULT_CONFIG['gitlab_group']:
|
|
96
|
+
# No usable config was found. The local files are resolved against the
|
|
97
|
+
# CURRENT directory, which trips people up when the config lives next to
|
|
98
|
+
# the example in the repo but the command is run from elsewhere — so show
|
|
99
|
+
# the exact paths searched (absolute) and whether each exists.
|
|
100
|
+
log("WARNING: gitlab_group is still the placeholder 'your-gitlab-group' — "
|
|
101
|
+
"no config with your group was found. Searched (low to high precedence):")
|
|
102
|
+
for path in (LEGACY_CONFIG_FILE, CONFIG_FILE, LEGACY_LOCAL_CONFIG_FILE,
|
|
103
|
+
LOCAL_CONFIG_FILE, config_path):
|
|
104
|
+
if not path:
|
|
105
|
+
continue
|
|
106
|
+
mark = "found" if os.path.exists(path) else "absent"
|
|
107
|
+
log(f" [{mark}] {os.path.abspath(path)}")
|
|
108
|
+
log(" Local files (.contextlake.ini) are read from the CURRENT directory. "
|
|
109
|
+
"Copy .contextlake.ini.example to one of the paths above (or pass "
|
|
110
|
+
"--config PATH) and set gitlab_group.")
|
|
111
|
+
|
|
112
|
+
return config
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_cache_paths(config):
|
|
116
|
+
"""Get cache file paths from config."""
|
|
117
|
+
cache_dir = config.get('cache_dir', '/tmp')
|
|
118
|
+
cache_file = config.get('cache_file', 'gitlab_projects.txt')
|
|
119
|
+
cache_json = config.get('cache_json', 'gitlab_projects.json')
|
|
120
|
+
return os.path.join(cache_dir, cache_file), os.path.join(cache_dir, cache_json)
|