Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/ndi/gui/nav/ndi_pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
7 changes: 6 additions & 1 deletion src/ndi/gui/ndi_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand Down
46 changes: 36 additions & 10 deletions src/ndi/update/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down
22 changes: 20 additions & 2 deletions src/ndi/update/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``,
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 13 additions & 10 deletions src/ndi/update/modification_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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:
Expand All @@ -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)
19 changes: 16 additions & 3 deletions src/ndi/update/ndi_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
58 changes: 54 additions & 4 deletions tests/test_update_check_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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")
Expand Down
Loading
Loading