Skip to content

Commit e91dfe8

Browse files
committed
app: manifest: Add the ability to display "untracked" files and dirs
This new command-line switch implements something similar to `git status`'s untracked files display. The code examines the workspace and prints out all files and directories that are not inside of a west project. Signed-off-by: Carles Cufi <[email protected]>
1 parent 6202160 commit e91dfe8

File tree

2 files changed

+175
-1
lines changed

2 files changed

+175
-1
lines changed

src/west/app/project.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,13 @@ def __init__(self):
584584
If this file uses imports, it will not contain all the
585585
manifest data.
586586
587+
- --untracked: print all files and directories inside the workspace that
588+
are not tracked or managed by west. This effectively means any
589+
file or directory that is outside all of the projects' directories on
590+
disk. This is similar to `git status` for untracked files. The
591+
output format is relative to the current working directory and is
592+
stable and suitable as input for scripting.
593+
587594
If the manifest file does not use imports, and all project
588595
revisions are SHAs, the --freeze and --resolve output will
589596
be identical after a "west update".
@@ -604,6 +611,9 @@ def do_add_parser(self, parser_adder):
604611
exiting with an error if there are issues''')
605612
group.add_argument('--path', action='store_true',
606613
help="print the top level manifest file's path")
614+
group.add_argument('--untracked', action='store_true',
615+
help='''print all files and directories not managed or
616+
tracked by west''')
607617

608618
group = parser.add_argument_group('options for --resolve and --freeze')
609619
group.add_argument('-o', '--out',
@@ -624,6 +634,8 @@ def do_run(self, args, user_args):
624634
elif args.freeze:
625635
self._die_if_manifest_project_filter('freeze')
626636
self._dump(args, manifest.as_frozen_yaml(**dump_kwargs))
637+
elif args.untracked:
638+
self._untracked()
627639
elif args.path:
628640
self.inf(manifest.path)
629641
else:
@@ -640,6 +652,70 @@ def _die_if_manifest_project_filter(self, action):
640652
'the manifest while projects are made inactive by the '
641653
'project filter.')
642654

655+
def _untracked(self):
656+
''' "Performs a top-down search of the west topdir,
657+
ignoring every directory that corresponds to a west project.
658+
'''
659+
ppaths = []
660+
untracked = []
661+
for project in self._projects(None):
662+
# We do not check for self.manifest.is_active(project) because
663+
# inactive projects are still considered "tracked directories".
664+
ppaths.append(Path(project.abspath))
665+
666+
# Since west tolerates nested projects (i.e. a project inside the directory
667+
# of another project) we must sort the project paths to ensure that we
668+
# hit the "enclosing" project first when iterating.
669+
ppaths.sort()
670+
671+
def _find_untracked(directory):
672+
'''There are three cases for each element in a directory:
673+
- It's a project -> Do nothing, ignore the directory.
674+
- There are no projects inside -> add to untracked list.
675+
- It's not a project directory but there are some projects inside it -> recurse.
676+
The directory argument cannot be inside a project, otherwise all bets are off.
677+
'''
678+
self.dbg(f'looking for untracked files/directories in: {directory}')
679+
for e in [e.absolute() for e in directory.iterdir()]:
680+
if not e.is_dir() or e.is_symlink():
681+
untracked.append(e)
682+
continue
683+
self.dbg(f'processing directory: {e}')
684+
for ppath in ppaths:
685+
# We cannot use samefile() because it requires the file
686+
# to exist (not always the case with inactive or even
687+
# uncloned projects).
688+
if ppath == e:
689+
# We hit a project root directory, skip it.
690+
break
691+
elif e in ppath.parents:
692+
self.dbg(f'recursing into: {e}')
693+
_find_untracked(e)
694+
break
695+
else:
696+
# This is not a project and there is no project inside.
697+
# Add to untracked elements.
698+
untracked.append(e)
699+
continue
700+
701+
# Avoid using Path.walk() since that returns all files and directories under
702+
# a particular directory, which is overkill in our case. Instead, recurse
703+
# only when required.
704+
_find_untracked(Path(self.topdir))
705+
706+
# Exclude the .west directory, which is maintained by west
707+
try:
708+
untracked.remove((Path(self.topdir) / Path(WEST_DIR)).resolve())
709+
except ValueError:
710+
self.die(f'Directory {WEST_DIR} not found in workspace')
711+
712+
# Sort the results for displaying to the user.
713+
untracked.sort()
714+
for u in untracked:
715+
# We cannot use Path.relative_to(p, walk_up=True) because the
716+
# walk_up parameter was only added in 3.12
717+
self.inf(os.path.relpath(u, Path.cwd()))
718+
643719
def _dump(self, args, to_dump):
644720
if args.out:
645721
with open(args.out, 'w') as f:
@@ -881,7 +957,10 @@ def __init__(self):
881957
'status',
882958
'"git status" for one or more projects',
883959
'''Runs "git status" for each of the specified projects.
884-
Unknown arguments are passed to "git status".''',
960+
Unknown arguments are passed to "git status".
961+
962+
Note: If you are looking to find untracked files and directories
963+
in the workspace use "west manifest --untracked".''',
885964
accepts_unknown_args=True,
886965
)
887966

tests/test_project.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
import shutil
77
import subprocess
8+
import sys
89
import textwrap
910
from pathlib import Path, PurePath
1011

@@ -239,6 +240,100 @@ def test_list_sha(west_update_tmpdir):
239240
assert cmd('list -f {sha}').startswith("N/A")
240241

241242

243+
def test_manifest_untracked(west_update_tmpdir):
244+
245+
def check(expected, cwd=None):
246+
out_lines = cmd("manifest --untracked", cwd=cwd).splitlines()
247+
assert out_lines == expected
248+
249+
# Ensure that all projects are active
250+
projs = cmd('list -f {name}').splitlines()
251+
assert projs == ['manifest', 'Kconfiglib', 'tagged_repo', 'net-tools']
252+
253+
# Disable Kconfiglib
254+
cmd('config manifest.group-filter -- -Kconfiglib-group')
255+
256+
# Ensure that Kconfiglib is inactive
257+
projs = cmd('list -f {name}').splitlines()
258+
assert projs == ['manifest', 'tagged_repo', 'net-tools']
259+
projs = cmd('list --all -f {name}').splitlines()
260+
assert projs == ['manifest', 'Kconfiglib', 'tagged_repo', 'net-tools']
261+
262+
topdir = Path(west_update_tmpdir)
263+
264+
# No untracked files yet
265+
check(list())
266+
267+
(topdir / "dir").mkdir()
268+
# Untracked dir
269+
check(['dir'])
270+
271+
(topdir / "unt").mkdir()
272+
(topdir / "unt" / "file.yml").touch()
273+
# Ensure we show the dir as untracked, not the file
274+
check(['dir', 'unt'])
275+
276+
(topdir / "subdir" / "new").mkdir()
277+
(topdir / "subdir" / "new" / "afile.txt").touch()
278+
check(['dir', str(Path('subdir/new')), 'unt'])
279+
280+
# Check relative paths
281+
check([str(Path('../dir')), str(Path('../subdir/new')), '.'], cwd=str(Path("unt/")))
282+
283+
# Add a file to an existing project, ignored by --untracked
284+
(topdir / "net-tools" / "test_manifest_untracked.file").touch()
285+
check(['dir', str(Path('subdir/new')), 'unt'])
286+
287+
kconfiglib = Path(topdir / "subdir" / "Kconfiglib")
288+
# Same but with an inactive project
289+
(kconfiglib / "test_manifest_untracked.file").touch()
290+
check(['dir', str(Path('subdir/new')), 'unt'])
291+
292+
# Empty a project so it's not a Git repo anymore
293+
(topdir / "net-tools" / ".git").rename(topdir / "net-tools" / "former-git")
294+
# Should make no difference
295+
check(['dir', str(Path('subdir/new')), 'unt'])
296+
297+
# Same with an inactive project
298+
(kconfiglib / ".git").rename(kconfiglib / "former-git")
299+
# Should make no difference
300+
check(['dir', str(Path('subdir/new')), 'unt'])
301+
302+
# Even if we make the whole inactive project disappear it should make no
303+
# difference at all, except that the renamed dir will show up.
304+
(kconfiglib).rename(topdir / "subdir" / "other")
305+
check(['dir', str(Path('subdir/new')), str(Path('subdir/other')), 'unt'])
306+
307+
@pytest.mark.skipif(sys.platform.startswith("win"), reason="symbolic links not tested on Windows")
308+
def test_manifest_untracked_with_symlinks(west_update_tmpdir):
309+
310+
def check(expected, cwd=None):
311+
out_lines = cmd("manifest --untracked", cwd=cwd).splitlines()
312+
assert out_lines == expected
313+
314+
# Disable Kconfiglib to have an inactive project
315+
cmd('config manifest.group-filter -- -Kconfiglib-group')
316+
317+
# Ensure that Kconfiglib is inactive
318+
projs = cmd('list -f {name}').splitlines()
319+
assert projs == ['manifest', 'tagged_repo', 'net-tools']
320+
321+
# Create a folder symlink
322+
Path('asl').symlink_to(Path('subdir/Kconfiglib'))
323+
# Create another one
324+
Path('anothersl').symlink_to(Path('../'))
325+
# Yet another one
326+
Path('subdir/yetanothersl').symlink_to(Path('tagged_repo'))
327+
# check that symlinks are displayed like any other regular untracked file or
328+
# directory
329+
check(['anothersl', 'asl', str(Path('subdir/yetanothersl'))])
330+
331+
# File symlink tests, should all be displayed as untracked as well
332+
Path('filesl.yml').symlink_to(Path('zephyr/west.yml'))
333+
Path('subdir/afsl.py').symlink_to(Path('net-tools/scripts/test.py'))
334+
check(['anothersl', 'asl', 'filesl.yml', str(Path('subdir/afsl.py')),
335+
str(Path('subdir/yetanothersl'))])
336+
242337
def test_manifest_freeze(west_update_tmpdir):
243338
# We should be able to freeze manifests.
244339
actual = cmd('manifest --freeze').splitlines()

0 commit comments

Comments
 (0)