Skip to content

Commit 6aeec04

Browse files
mballanceCopilot
andcommitted
Redesign sync command with delegation, TUI, dry-run, and parallelism
- Add SyncOutcome enum and PkgSyncResult dataclass (pkg_sync.py) mirroring the status command's result types - Add SyncProgressListener callback interface for live parallel progress - Implement PackageGit.sync() with full logic: tag-pinned, read-only, detached HEAD, dirty detection, fetch, ahead/behind, dry-run variants, real merge with conflict detection + abort, submodule update - Implement ProjectOps.sync() with asyncio.gather + Semaphore parallelism; progress events fired from async wrapper (clean separation from pkg classes) - Add RichSyncTUI (live spinner + final Rich table) and TranscriptSyncTUI (plain-text >> / << progress + final table); both implement SyncProgressListener - Replace 139-line CmdSync monolith with 38-line delegator that wires TUI as progress listener and calls tui.start()/stop() around sync - Add -n/--dry-run, -j/--jobs, -p/--packages, --no-rich to sync subcommand - Add patch_lock_after_sync() and _write_lock_dict() to package_lock.py; fix SHA256 handling in lock rewrite - Add 21 unit tests in test_sync_ops.py covering all outcomes, dry-run, parallel execution, and progress callbacks (all local git repos, no network) - Exit code: only SyncOutcome.ERROR causes sys.exit(1); CONFLICT/DIRTY/AHEAD are informational (exit 0) - Add sync-design.md design document at repo root Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e9fa579 commit 6aeec04

File tree

12 files changed

+1883
-158
lines changed

12 files changed

+1883
-158
lines changed

src/ivpm/__main__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,13 @@ def get_parser(parser_ext : List = None, options_ext : List = None):
220220
sync_cmd = subparser.add_parser("sync",
221221
help="Synchronizes dependent packages with an upstream source (if available)")
222222
sync_cmd.set_defaults(func=CmdSync())
223+
sync_cmd.add_argument("-p", "--project-dir", dest="project_dir", default=None)
224+
sync_cmd.add_argument("-n", "--dry-run", dest="dry_run", action="store_true",
225+
default=False, help="Fetch and report sync-ability without merging")
226+
sync_cmd.add_argument("-j", "--jobs", dest="jobs", type=int, default=0,
227+
help="Number of parallel sync operations (default: CPU count)")
228+
sync_cmd.add_argument("--no-rich", action="store_true", default=False,
229+
help="Plain-text output without Rich formatting")
223230

224231
status_cmd = subparser.add_parser("status",
225232
help="Checks the status of sub-dependencies such as git repositories")

src/ivpm/cmds/cmd_sync.py

Lines changed: 18 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -4,135 +4,39 @@
44
@author: mballance
55
'''
66
import os
7-
import subprocess
87
import sys
9-
import stat
108

11-
from ivpm.arg_utils import ensure_have_project_dir
12-
from ivpm.msg import fatal, note
139
from ..project_ops import ProjectOps
14-
from ..proj_info import ProjInfo
15-
from ..package_lock import write_lock, read_lock
16-
10+
from ..pkg_sync import SyncOutcome
11+
from ..sync_tui import create_sync_tui
1712

1813

1914
class CmdSync(object):
20-
15+
2116
def __init__(self):
2217
pass
23-
18+
2419
def __call__(self, args):
2520
if args.project_dir is None:
2621
args.project_dir = os.getcwd()
27-
28-
proj_info = ProjInfo.mkFromProj(args.project_dir)
29-
30-
if proj_info is None:
31-
fatal("Failed to locate IVPM meta-data (eg ivpm.yaml)")
32-
33-
packages_dir = os.path.join(args.project_dir, proj_info.deps_dir)
34-
35-
# After that check, go ahead and just check directories
36-
for dir in os.listdir(packages_dir):
37-
pkg_path = os.path.join(packages_dir, dir)
38-
git_dir = os.path.join(pkg_path, ".git")
39-
40-
if os.path.isdir(git_dir):
41-
# Check if the package is editable by testing if it's writable
42-
# Cached (non-editable) packages are made read-only
43-
try:
44-
mode = os.stat(pkg_path).st_mode
45-
is_writable = bool(mode & stat.S_IWUSR)
46-
except Exception:
47-
is_writable = False
48-
49-
if not is_writable:
50-
print("Note: skipping cached (read-only) package \"%s\"" % dir)
51-
continue
52-
53-
print("Package: " + dir)
54-
cwd = os.getcwd()
55-
os.chdir(pkg_path)
56-
try:
57-
branch = subprocess.check_output(["git", "branch"])
58-
except Exception as e:
59-
print("Note: Failed to get branch of package \"" + dir + "\"")
60-
continue
61-
62-
branch = branch.strip()
63-
if len(branch) == 0:
64-
raise Exception("Error: branch is empty")
6522

66-
branch_lines = branch.decode().splitlines()
67-
branch = None
68-
for bl in branch_lines:
69-
if bl[0] == "*":
70-
branch = bl[1:].strip()
71-
break
72-
if branch is None:
73-
raise Exception("Failed to identify branch")
23+
dry_run = getattr(args, "dry_run", False)
24+
tui = create_sync_tui(args)
7425

75-
status = subprocess.run(["git", "fetch"])
76-
if status.returncode != 0:
77-
fatal("Failed to run git fetch on package %s" % dir)
78-
status = subprocess.run(["git", "merge", "origin/" + branch])
79-
if status.returncode != 0:
80-
fatal("Failed to run git merge origin/%s on package %s" % (branch, dir))
81-
os.chdir(cwd)
82-
elif os.path.isdir(pkg_path):
83-
print("Note: skipping non-Git package \"" + dir + "\"")
84-
sys.stdout.flush()
85-
86-
# Update package-lock.json to reflect the new state of all packages.
87-
# We re-read the current commit hashes by patching the existing lock
88-
# entries — this preserves all non-git package entries unchanged.
89-
self._update_lock_after_sync(packages_dir)
90-
91-
def _update_lock_after_sync(self, packages_dir: str):
92-
"""Regenerate package-lock.json after sync by reading current HEAD commits."""
93-
lock_path = os.path.join(packages_dir, "package-lock.json")
94-
if not os.path.isfile(lock_path):
95-
return # No lock file to update
26+
# Wire the TUI as the live progress listener so it receives
27+
# per-package notifications during the parallel sync.
28+
args._sync_progress = tui
9629

30+
tui.start()
9731
try:
98-
lock = read_lock(lock_path)
99-
except Exception as e:
100-
note("Warning: could not read package-lock.json for update: %s" % e)
101-
return
102-
103-
packages = lock.get("packages", {})
104-
105-
for name, entry in packages.items():
106-
if entry.get("src") != "git":
107-
continue
108-
pkg_path = os.path.join(packages_dir, name)
109-
if not os.path.isdir(os.path.join(pkg_path, ".git")):
110-
continue
111-
try:
112-
result = subprocess.run(
113-
["git", "rev-parse", "HEAD"],
114-
capture_output=True, text=True, cwd=pkg_path, timeout=10
115-
)
116-
if result.returncode == 0:
117-
entry["commit_resolved"] = result.stdout.strip()
118-
except Exception:
119-
pass
120-
121-
# Re-use write_lock's atomic write by building a minimal fake pkgs dict.
122-
# Simpler: just write the patched lock dict directly (atomically).
123-
import json, hashlib
124-
from datetime import datetime, timezone
32+
results = ProjectOps(args.project_dir).sync(args=args)
33+
finally:
34+
tui.stop()
12535

126-
lock["generated"] = datetime.now(timezone.utc).isoformat()
127-
lock.pop("sha256", None)
128-
body = json.dumps(lock, indent=2, sort_keys=True)
129-
checksum = hashlib.sha256(body.encode()).hexdigest()
130-
lock["sha256"] = checksum
36+
tui.render(results, dry_run=dry_run)
13137

132-
tmp_path = lock_path + ".tmp"
133-
with open(tmp_path, "w") as f:
134-
json.dump(lock, indent=2, sort_keys=True, fp=f)
135-
f.write("\n")
136-
os.replace(tmp_path, lock_path)
137-
note("Updated package-lock.json after sync")
38+
# Only exit non-zero on true fatal errors (network failure, git crash, etc.).
39+
# CONFLICT, DIRTY, and AHEAD are informational — the tool completed successfully.
40+
if any(r.outcome == SyncOutcome.ERROR for r in results):
41+
sys.exit(1)
13842

src/ivpm/package.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,15 @@ def build(self, pkgs_info):
112112
def status(self, pkgs_info):
113113
pass
114114

115-
def sync(self, pkgs_info):
116-
pass
115+
def sync(self, sync_info):
116+
from .pkg_sync import PkgSyncResult, SyncOutcome
117+
return PkgSyncResult(
118+
name=self.name,
119+
src_type=str(getattr(self, "src_type", "") or ""),
120+
path=os.path.join(sync_info.deps_dir, self.name),
121+
outcome=SyncOutcome.SKIPPED,
122+
skipped_reason="not a VCS package",
123+
)
117124

118125
def update(self, update_info : ProjectUpdateInfo) -> 'ProjInfo':
119126
from .proj_info import ProjInfo

src/ivpm/package_lock.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,73 @@ def write_lock(
173173
os.replace(tmp_path, lock_path)
174174
_logger.info("Wrote package-lock.json (%d packages)", len(packages))
175175

176+
def _write_lock_dict(lock_path: str, lock: dict) -> None:
177+
"""Atomically (re-)write a lock dict to *lock_path*.
178+
179+
Strips any stale ``sha256`` key, refreshes the ``generated`` timestamp,
180+
recomputes the checksum over the canonical body (without the checksum
181+
key), then inserts it before the final write. This is the single place
182+
where the lock-file integrity stamp is managed.
183+
"""
184+
lock = dict(lock) # shallow copy — don't mutate caller's dict
185+
lock.pop("sha256", None)
186+
lock["generated"] = datetime.now(timezone.utc).isoformat()
187+
188+
body = json.dumps(lock, indent=2, sort_keys=True)
189+
lock["sha256"] = hashlib.sha256(body.encode()).hexdigest()
190+
191+
tmp_path = lock_path + ".tmp"
192+
with open(tmp_path, "w") as f:
193+
json.dump(lock, indent=2, sort_keys=True, fp=f)
194+
f.write("\n")
195+
os.replace(tmp_path, lock_path)
196+
197+
198+
def patch_lock_after_sync(lock_path: str, sync_results) -> None:
199+
"""Update ``commit_resolved`` for synced packages and re-write the lock.
200+
201+
Called by ``ProjectOps.sync()`` after a successful (non-dry-run) sync.
202+
Only packages whose outcome is SYNCED are updated; all others are left
203+
unchanged.
204+
"""
205+
from .pkg_sync import SyncOutcome
206+
207+
if not os.path.isfile(lock_path):
208+
return
209+
210+
try:
211+
lock = read_lock(lock_path)
212+
except Exception as e:
213+
_logger.warning("Could not read package-lock.json for sync update: %s", e)
214+
return
215+
216+
packages = lock.get("packages", {})
217+
changed = False
218+
for result in sync_results:
219+
if result.outcome != SyncOutcome.SYNCED:
220+
continue
221+
if result.name not in packages:
222+
continue
223+
entry = packages[result.name]
224+
if entry.get("src") != "git":
225+
continue
226+
# Re-read full commit hash from the working directory
227+
try:
228+
r = subprocess.run(
229+
["git", "rev-parse", "HEAD"],
230+
capture_output=True, text=True, cwd=result.path, timeout=10,
231+
)
232+
if r.returncode == 0:
233+
entry["commit_resolved"] = r.stdout.strip()
234+
changed = True
235+
except Exception:
236+
pass
237+
238+
if changed:
239+
_write_lock_dict(lock_path, lock)
240+
_logger.info("Updated package-lock.json after sync")
241+
176242

177-
# ---------------------------------------------------------------------------
178-
# Read
179-
# ---------------------------------------------------------------------------
180243

181244
def read_lock(lock_path: str) -> dict:
182245
"""Read and validate a lock file. Returns the parsed dict."""

src/ivpm/pkg_sync.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#****************************************************************************
2+
#* pkg_sync.py
3+
#*
4+
#* Copyright 2024 Matthew Ballance and Contributors
5+
#*
6+
#* Licensed under the Apache License, Version 2.0 (the "License"); you may
7+
#* not use this file except in compliance with the License.
8+
#* You may obtain a copy of the License at:
9+
#*
10+
#* http://www.apache.org/licenses/LICENSE-2.0
11+
#*
12+
#* Unless required by applicable law or agreed to in writing, software
13+
#* distributed under the License is distributed on an "AS IS" BASIS,
14+
#* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
#* See the License for the specific language governing permissions and
16+
#* limitations under the License.
17+
#*
18+
#****************************************************************************
19+
"""
20+
pkg_sync.py — result types for `ivpm sync`.
21+
22+
Mirrors pkg_status.py; returned by Package.sync() implementations.
23+
"""
24+
import dataclasses as dc
25+
import enum
26+
from typing import List, Optional
27+
28+
29+
class SyncOutcome(str, enum.Enum):
30+
UP_TO_DATE = "up-to-date" # already at latest
31+
SYNCED = "synced" # fast-forward merge succeeded
32+
CONFLICT = "conflict" # merge conflict — user must resolve
33+
DIRTY = "dirty" # uncommitted changes block merge
34+
AHEAD = "ahead" # local commits not on origin
35+
ERROR = "error" # network / git command failure
36+
SKIPPED = "skipped" # read-only / non-git / tag-pinned
37+
38+
# dry-run variants (--dry-run / -n)
39+
DRY_WOULD_SYNC = "dry:sync" # would fast-forward cleanly
40+
DRY_WOULD_CONFLICT = "dry:conflict" # diverged — would produce conflicts
41+
DRY_DIRTY = "dry:dirty" # dirty tree would block merge
42+
43+
44+
class SyncProgressListener:
45+
"""Callback interface for live sync progress.
46+
47+
Implement and pass as ``ProjectSyncInfo.progress`` to receive
48+
per-package notifications during a (potentially parallel) sync.
49+
"""
50+
def on_pkg_start(self, name: str) -> None:
51+
"""Called just before a package's sync work begins."""
52+
53+
def on_pkg_result(self, result: 'PkgSyncResult') -> None:
54+
"""Called immediately after a package's sync work finishes."""
55+
56+
57+
@dc.dataclass
58+
class PkgSyncResult:
59+
"""Per-package sync result returned by Package.sync()."""
60+
name: str
61+
src_type: str
62+
path: str
63+
outcome: SyncOutcome
64+
branch: Optional[str] = None
65+
old_commit: Optional[str] = None # short commit before merge
66+
new_commit: Optional[str] = None # short commit after merge (or would-be)
67+
commits_behind: Optional[int] = None # upstream commits pulled / to be pulled
68+
commits_ahead: Optional[int] = None # local commits not on origin
69+
conflict_files: List[str] = dc.field(default_factory=list)
70+
dirty_files: List[str] = dc.field(default_factory=list)
71+
next_steps: List[str] = dc.field(default_factory=list) # shell commands to show
72+
error: Optional[str] = None
73+
skipped_reason: Optional[str] = None

0 commit comments

Comments
 (0)