From 9269101b8c5f9d97306414be8bb87e317607273f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:37:53 +0000 Subject: [PATCH] Updater: leave untracked files alone; stop only for ones in the way Mirrors VH-Lab/NDI-matlab a24f19076. ndi.update.check counted every untracked file as a modification, so install asked "Update anyway?" (default No) or, with Confirm=False, refused -- although git stash and git pull never touch untracked files. Untracked files no longer count. An untracked file at a path the update adds a file to is the one case that matters (git refuses to overwrite it and the pull stops part-way): those are reported as BlockingFiles, replacing NewFiles, install stops before starting with ndi:update:install:untrackedFilesInTheWay naming them, and the Update button's dialog says to move them. Bridge entries for check, install, modificationSummary and ndiPane re-synced to a24f19076. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Ni686VRCwcmMfvCVcLypp --- src/ndi/gui/nav/ndi_pane.py | 12 +++- src/ndi/gui/ndi_matlab_python_bridge.yaml | 7 ++- src/ndi/update/check.py | 46 ++++++++++++---- src/ndi/update/install.py | 22 +++++++- src/ndi/update/modification_summary.py | 23 ++++---- src/ndi/update/ndi_matlab_python_bridge.yaml | 19 ++++++- tests/test_update_check_git.py | 58 ++++++++++++++++++-- tests/test_update_misc.py | 37 ++++++++++--- 8 files changed, 185 insertions(+), 39 deletions(-) diff --git a/src/ndi/gui/nav/ndi_pane.py b/src/ndi/gui/nav/ndi_pane.py index 67dbff2d..c569fedc 100644 --- a/src/ndi/gui/nav/ndi_pane.py +++ b/src/ndi/gui/nav/ndi_pane.py @@ -80,9 +80,19 @@ def update_dialog(result: dict[str, Any]) -> dict[str, Any]: if result.get("HasModifications"): from ...update import modificationSummary + summary = modificationSummary(result) + if any(m.get("BlockingFiles") for m in result.get("Modifications", [])): + # Untracked files sit where the update adds files; git will not + # overwrite them, so updating cannot work until they move. + return { + "kind": "alert", + "message": f"{message}\n\n{summary}\n\nMove or rename the files marked " + '"in the way", then press Update again.', + "success": False, + } return { "kind": "confirm", - "message": f"{message}\n\n{modificationSummary(result)}\n\nUpdate anyway?", + "message": f"{message}\n\n{summary}\n\nUpdate anyway?", "accept": "Update anyway", "default_cancel": True, } diff --git a/src/ndi/gui/ndi_matlab_python_bridge.yaml b/src/ndi/gui/ndi_matlab_python_bridge.yaml index 3d727b33..76528fda 100644 --- a/src/ndi/gui/ndi_matlab_python_bridge.yaml +++ b/src/ndi/gui/ndi_matlab_python_bridge.yaml @@ -748,7 +748,7 @@ ported_late: - name: ndiPane matlab_path: "+ndi/+gui/+nav/ndiPane.m" - matlab_last_sync_hash: "f9ced4bcd" + matlab_last_sync_hash: "a24f19076" python_path: "ndi/gui/nav/ndi_pane.py" decision_log: > The NDI-branded pane. Ported in #88. @@ -762,6 +762,11 @@ ported_late: uiconfirm does) and a wait cursor stands in for uiprogressdlg. MATLAB's button also offers NDI-python updates from MATLAB; Python's checks NDI-python only. + 2026-10-19: NDI-matlab a24f19076: untracked files no longer count as the user's + modifications (git stash and pull leave them alone); only untracked files + at a path the update adds a file to are reported, as BlockingFiles + (replacing NewFiles), and the Update button shows them in a + warning and asks for them to be moved instead of offering the update. Ported the same. - name: datasetsPane matlab_path: "+ndi/+gui/+nav/datasetsPane.m" diff --git a/src/ndi/update/check.py b/src/ndi/update/check.py index 671ae235..654dde6a 100644 --- a/src/ndi/update/check.py +++ b/src/ndi/update/check.py @@ -96,8 +96,11 @@ def check( ``UpdateAvailable``, ``CurrentVersion``, ``LatestVersion``, ``Message``, ``Branch``, ``CommitsBehind``, ``CommitsAhead``, ``HasModifications``, ``Modifications`` (one dict per repository - with ``Name``, ``Path``, ``ChangedFiles``, ``NewFiles`` and - ``CommitsAhead``), ``Dependencies`` (``Name``, ``Path``, ``Status``, + where updating touches the user's work, with ``Name``, ``Path``, + ``ChangedFiles``, ``BlockingFiles`` -- untracked files at a path the + update adds a file to, which git will not overwrite -- and + ``CommitsAhead``; other untracked files are left alone and not + listed), ``Dependencies`` (``Name``, ``Path``, ``Status``, ``CommitsBehind``) and ``RepoRoot``. Python-specific Notes: @@ -171,7 +174,9 @@ def _check_git(result: dict[str, Any], info: dict[str, Any], include_deps: bool) result["CommitsBehind"] = _to_int(behind) result["CommitsAhead"] = _to_int(ahead) - mods = _local_modifications("NDI-python", repo, result["CommitsAhead"]) + mods = _local_modifications( + "NDI-python", repo, result["CommitsAhead"], _incoming_new_files(repo, "origin/main") + ) if include_deps: tools = Path(info.get("NdiHome") or Path.home() / ".ndi") / "tools" deps, dep_mods = _check_dependencies(tools) @@ -239,7 +244,12 @@ def _check_dependencies(tools: Path) -> tuple[list[dict[str, Any]], list[dict[st counted, behind = run_git(path, "rev-list", "--count", "HEAD..@{u}") ahead_status, ahead = run_git(path, "rev-list", "--count", "@{u}..HEAD") mods.extend( - _local_modifications(path.name, path, _to_int(ahead) if ahead_status == 0 else 0) + _local_modifications( + path.name, + path, + _to_int(ahead) if ahead_status == 0 else 0, + _incoming_new_files(path, "@{u}"), + ) ) if fetched == 0 and counted == 0: dep["CommitsBehind"] = _to_int(behind) @@ -249,29 +259,45 @@ def _check_dependencies(tools: Path) -> tuple[list[dict[str, Any]], list[dict[st def _local_modifications( - name: str, repo: str | Path, commits_ahead: int | None + name: str, repo: str | Path, commits_ahead: int | None, incoming: list[str] ) -> list[dict[str, Any]]: - """A one-element list if REPO has edited, added or unpushed code, else [].""" - status, out = run_git(repo, "status", "--porcelain") + """A one-element list if updating REPO would touch the user's work, else []. + + That is: edited tracked files, commits not upstream, or untracked files + at a path the update adds a file to (``incoming``). Other untracked files + are not the update's business: git stash and git pull leave them alone. + """ + # --untracked-files=all lists each file, not just an untracked folder, so + # an untracked file can be matched against the update's new paths. + status, out = run_git(repo, "status", "--porcelain", "--untracked-files=all") if status != 0: return [] lines = [line for line in out.splitlines() if len(line) > 3] changed = [line[3:] for line in lines if not line.startswith("??")] - added = [line[3:] for line in lines if line.startswith("??")] + incoming_set = set(incoming) + blocking = [line[3:] for line in lines if line.startswith("??") and line[3:] in incoming_set] commits_ahead = commits_ahead or 0 - if not changed and not added and commits_ahead == 0: + if not changed and not blocking and commits_ahead == 0: return [] return [ { "Name": name, "Path": str(repo), "ChangedFiles": changed, - "NewFiles": added, + "BlockingFiles": blocking, "CommitsAhead": commits_ahead, } ] +def _incoming_new_files(repo: str | Path, upstream: str) -> list[str]: + """Paths that the update (HEAD -> UPSTREAM) adds as new files.""" + status, out = run_git(repo, "diff", "--name-only", "--diff-filter=A", "HEAD", upstream) + if status != 0: + return [] + return [line for line in out.splitlines() if line] + + # --------------------------------------------------------------------------- # installer installs # --------------------------------------------------------------------------- diff --git a/src/ndi/update/install.py b/src/ndi/update/install.py index f4ae3c48..f8cf9adc 100644 --- a/src/ndi/update/install.py +++ b/src/ndi/update/install.py @@ -61,9 +61,12 @@ def install( ``~/.ndi/tools`` and reinstalls NDI-python and its dependencies into this environment. Changed files are stashed for the pull and put back afterwards. The clone must be on main. If you have changed code - (edited or added files, or local commits) in NDI-python or a + (edited tracked files, or local commits) in NDI-python or a dependency, the changes are listed first and you are asked whether to - update anyway; the default answer is No. + update anyway; the default answer is No. Untracked files of your own + are left alone and need no answer, unless one is at a path the update + adds a file to: git will not overwrite it, so the update stops before + starting and asks you to move it. * **installer**: runs the NDI installer again (``install-ndi.sh --yes --no-path``), which reinstalls NDI-python and all its dependencies from main. On Windows the running ``ndi`` cannot be replaced while it @@ -91,6 +94,7 @@ def install( Raises: UpdateError: ``ndi:update:install:notOnMain``, + ``ndi:update:install:untrackedFilesInTheWay``, ``ndi:update:install:localModifications``, ``ndi:update:install:noInstaller``, ``ndi:update:install:pullFailed``, @@ -135,6 +139,20 @@ def _install_git( "from main; run 'git checkout main' there first.", ) + blocking = [ + str(Path(m["Path"]) / f) for m in result["Modifications"] for f in m["BlockingFiles"] + ] + if blocking: + # git pull would stop part-way rather than overwrite these, so no answer + # to "update anyway?" could make the update work. + raise UpdateError( + "ndi:update:install:untrackedFilesInTheWay", + "The update adds files at these paths, where you have untracked files " + "of your own. git will not overwrite them, so the update would stop " + "part-way. Move or rename them, then update again:\n" + + "\n".join(f" {f}" for f in blocking), + ) + if result["HasModifications"]: summary = modificationSummary(result, MaxFiles=25) if confirm: diff --git a/src/ndi/update/modification_summary.py b/src/ndi/update/modification_summary.py index 6e61788e..afcdaa66 100644 --- a/src/ndi/update/modification_summary.py +++ b/src/ndi/update/modification_summary.py @@ -22,9 +22,11 @@ def modificationSummary(result: dict[str, Any], MaxFiles: int = Field(10, gt=0)) TXT = ndi.update.modificationSummary(RESULT, MaxFiles=N) Given RESULT from :func:`ndi.update.check`, returns a human-readable - string listing, for each repository with modifications, the changed - files, the new files and the number of local commits, followed by what - updating does to each kind of change. Returns ``""`` if there are none. + string listing, for each repository where updating touches the user's + work, the changed files, the untracked files in the way of files the + update adds, and the number of local commits, followed by what updating + does to each. Returns ``""`` if there are none. Other untracked files are + not listed: the update leaves them where they are. At most ``MaxFiles`` files are listed per repository; the rest are counted. """ @@ -37,14 +39,14 @@ def modificationSummary(result: dict[str, Any], MaxFiles: int = Field(10, gt=0)) counts = [] if m["ChangedFiles"]: counts.append(f"{len(m['ChangedFiles'])} changed file(s)") - if m["NewFiles"]: - counts.append(f"{len(m['NewFiles'])} new file(s)") + if m["BlockingFiles"]: + counts.append(f"{len(m['BlockingFiles'])} untracked file(s) in the way of new files") if m["CommitsAhead"] > 0: counts.append(f"{m['CommitsAhead']} local commit(s)") parts.append(f"\n{m['Name']} ({m['Path']}): " + ", ".join(counts)) - files = [f" changed: {f}" for f in m["ChangedFiles"]] + [ - f" new: {f}" for f in m["NewFiles"] + files = [f" changed: {f}" for f in m["ChangedFiles"]] + [ + f" in the way: {f}" for f in m["BlockingFiles"] ] parts.extend(files[:MaxFiles]) if len(files) > MaxFiles: @@ -53,8 +55,9 @@ def modificationSummary(result: dict[str, Any], MaxFiles: int = Field(10, gt=0)) parts.append( "\nIf you update: changed files are stashed and put back after the " "update (if they no longer apply cleanly, 'git stash list' in that " - "folder still has them), new files are left in place, and local " - "commits are merged with the update, which can stop with a merge " - "conflict." + "folder still has them), and local commits are merged with the " + "update, which can stop with a merge conflict. An untracked file in " + "the way of a file the update adds must be moved or renamed first: " + "git will not overwrite it. Your other untracked files are left alone." ) return "\n".join(parts) diff --git a/src/ndi/update/ndi_matlab_python_bridge.yaml b/src/ndi/update/ndi_matlab_python_bridge.yaml index c500f50b..500ce1e2 100644 --- a/src/ndi/update/ndi_matlab_python_bridge.yaml +++ b/src/ndi/update/ndi_matlab_python_bridge.yaml @@ -19,7 +19,7 @@ functions: - name: check type: function matlab_path: "+ndi/+update/check.m" - matlab_last_sync_hash: "f9ced4bcd" + matlab_last_sync_hash: "a24f19076" python_path: "ndi/update/check.py" input_arguments: - name: IncludeDependencies @@ -60,11 +60,15 @@ functions: NDI-matlab checks this same install. Adds CanInstall and InstalledAt (both from checkPython's result) and drops ReleaseUrl, MltbxUrl, BothInstalled and Python. NaN counts are None. + 2026-10-19: NDI-matlab a24f19076: untracked files no longer count as the user's + modifications (git stash and pull leave them alone); only untracked files + at a path the update adds a file to are reported, as BlockingFiles + (replacing NewFiles), and they set HasModifications. Ported the same. - name: install type: function matlab_path: "+ndi/+update/install.m" - matlab_last_sync_hash: "f9ced4bcd" + matlab_last_sync_hash: "a24f19076" python_path: "ndi/update/install.py" input_arguments: - name: Confirm @@ -98,6 +102,11 @@ functions: install-ndi.ps1 in its own window, waiting for this process to exit, because a running ndi.exe cannot be replaced. Clears caches afterwards, as MATLAB does. Ask stands in for MATLAB's input (tests). + 2026-10-19: NDI-matlab a24f19076: untracked files no longer count as the user's + modifications (git stash and pull leave them alone); only untracked files + at a path the update adds a file to are reported, as BlockingFiles + (replacing NewFiles), and install stops before starting with + ndi:update:install:untrackedFilesInTheWay, even with AllowModified. Ported the same. - name: installType type: function @@ -121,7 +130,7 @@ functions: - name: modificationSummary type: function matlab_path: "+ndi/+update/modificationSummary.m" - matlab_last_sync_hash: "f9ced4bcd" + matlab_last_sync_hash: "a24f19076" python_path: "ndi/update/modification_summary.py" input_arguments: - name: result @@ -135,6 +144,10 @@ functions: Ported 2026-10-18 against NDI-matlab f9ced4bcd. Same layout. The closing sentence says changed files are put back after the update, because Python's git update restores the stash (see install). + 2026-10-19: NDI-matlab a24f19076: untracked files no longer count as the user's + modifications (git stash and pull leave them alone); only untracked files + at a path the update adds a file to are reported, as BlockingFiles + (replacing NewFiles), and the summary lists them as "in the way". Ported the same. - name: checkOnStartup type: function diff --git a/tests/test_update_check_git.py b/tests/test_update_check_git.py index 896ee3b3..9ff8ab65 100644 --- a/tests/test_update_check_git.py +++ b/tests/test_update_check_git.py @@ -156,14 +156,15 @@ def test_modifications_are_listed(clones): by_name = {m["Name"]: m for m in r["Modifications"]} assert set(by_name) == {"NDI-python", "vhlab-toolbox-python"} assert by_name["NDI-python"]["ChangedFiles"] == ["README.md"] - assert by_name["NDI-python"]["NewFiles"] == ["mine.py"] + # An untracked file the update does not touch is not listed. + assert by_name["NDI-python"]["BlockingFiles"] == [] assert by_name["NDI-python"]["CommitsAhead"] == 0 assert by_name["vhlab-toolbox-python"]["CommitsAhead"] == 1 assert "changed code in" in r["Message"] txt = modificationSummary(r) - assert "changed: README.md" in txt - assert "new: mine.py" in txt + assert "changed: README.md" in txt + assert "mine.py" not in txt assert "1 local commit(s)" in txt @@ -185,12 +186,61 @@ def test_install_allow_modified_goes_on(clones): # Past the modification guard, the next step needs ndi_install.py, # which the fake clone lacks. clones.push_upstream("NDI-python") - (clones.ndi / "mine.py").write_text("y = 2\n") + (clones.ndi / "local.py").write_text("x = 1\n") + commit_all(clones.ndi, "local work") with pytest.raises(UpdateError) as err: run_install(clones, AllowModified=True) assert err.value.identifier == "ndi:update:install:noInstaller" +def test_untracked_files_need_no_consent(clones): + # git stash and git pull leave untracked files alone, so they are not + # "modifications": the update goes ahead without asking (and, in this fake + # clone, stops only for want of ndi_install.py). + clones.push_upstream("NDI-python") + (clones.ndi / "mine.py").write_text("y = 2\n") + (clones.ndi / "scratch").mkdir() + (clones.ndi / "scratch" / "notes.py").write_text("z = 3\n") + r = run_check(clones) + assert r["UpdateAvailable"] is True + assert r["HasModifications"] is False + assert r["Modifications"] == [] + assert "changed code" not in r["Message"] + with pytest.raises(UpdateError) as err: + run_install(clones) + assert err.value.identifier == "ndi:update:install:noInstaller" + + +def test_untracked_file_in_the_way_stops_the_update(clones): + # The update adds files where the user has untracked ones; git would refuse + # to overwrite them and stop part-way, so install stops before starting and + # names them -- even with AllowModified, since no answer could make the + # pull work. + seed = clones.seeds / "NDI-python" + (seed / "newfile.py").write_text("upstream\n") + (seed / "sub").mkdir() + (seed / "sub" / "added.py").write_text("upstream\n") + commit_all(seed, "upstream adds files") + git(seed, "push", "-q", "origin", "main") + (clones.ndi / "newfile.py").write_text("mine\n") + (clones.ndi / "sub").mkdir() + (clones.ndi / "sub" / "added.py").write_text("mine too\n") + (clones.ndi / "harmless.py").write_text("left alone\n") + + r = run_check(clones) + assert r["HasModifications"] is True + assert sorted(r["Modifications"][0]["BlockingFiles"]) == ["newfile.py", "sub/added.py"] + assert r["Modifications"][0]["ChangedFiles"] == [] + txt = modificationSummary(r) + assert "in the way: newfile.py" in txt + assert "harmless.py" not in txt + for allow in (False, True): + with pytest.raises(UpdateError) as err: + run_install(clones, AllowModified=allow) + assert err.value.identifier == "ndi:update:install:untrackedFilesInTheWay" + assert (clones.ndi / "newfile.py").read_text() == "mine\n" + + def test_install_refuses_other_branch(clones): clones.push_upstream("NDI-python") git(clones.ndi, "checkout", "-q", "-b", "myFeature") diff --git a/tests/test_update_misc.py b/tests/test_update_misc.py index 7aeb34db..86fbbce9 100644 --- a/tests/test_update_misc.py +++ b/tests/test_update_misc.py @@ -33,12 +33,12 @@ # ---- modificationSummary --------------------------------------------------- -def mod(name="NDI-python", changed=(), new=(), ahead=0): +def mod(name="NDI-python", changed=(), blocking=(), ahead=0): return { "Name": name, "Path": f"/x/{name}", "ChangedFiles": list(changed), - "NewFiles": list(new), + "BlockingFiles": list(blocking), "CommitsAhead": ahead, } @@ -49,21 +49,22 @@ def test_no_modifications_gives_empty(): def test_summary_lists_files_and_commits(): txt = modificationSummary( - {"Modifications": [mod(changed=["src/a.py", "src/b.py"], new=["mine.py"], ahead=1)]} + {"Modifications": [mod(changed=["src/a.py", "src/b.py"], blocking=["mine.py"], ahead=1)]} ) assert "NDI-python (/x/NDI-python)" in txt assert "2 changed file(s)" in txt - assert "1 new file(s)" in txt + assert "1 untracked file(s) in the way of new files" in txt assert "1 local commit(s)" in txt - assert "changed: src/a.py" in txt - assert "new: mine.py" in txt + assert "changed: src/a.py" in txt + assert "in the way: mine.py" in txt assert "stash" in txt + assert "other untracked files are left alone" in txt def test_max_files_counts_the_rest(): files = [f"f{i}.py" for i in range(1, 6)] txt = modificationSummary({"Modifications": [mod(changed=files)]}, MaxFiles=2) - assert "changed: f2.py" in txt + assert "changed: f2.py" in txt assert "f3.py" not in txt assert "... and 3 more" in txt @@ -245,5 +246,25 @@ def test_dialog_with_modified_code_defaults_to_cancel(): ) assert plan["accept"] == "Update anyway" assert plan["default_cancel"] is True - assert "changed: src/a.py" in plan["message"] + assert "changed: src/a.py" in plan["message"] assert "Note: you have changed code" not in plan["message"] + + +def test_dialog_with_untracked_files_in_the_way_only_explains(): + # git will not overwrite them, so the button says to move them instead of + # offering an update that would stop part-way. + plan = update_dialog( + { + "UpdateAvailable": True, + "CanInstall": True, + "InstallType": "git", + "Branch": "main", + "HasModifications": True, + "Modifications": [mod(blocking=["new_module.py"])], + "Message": "An update is available.", + } + ) + assert plan["kind"] == "alert" + assert plan["success"] is False + assert "in the way: new_module.py" in plan["message"] + assert "Move or rename" in plan["message"]