Skip to content

Commit eac8dbc

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 eac8dbc

File tree

2 files changed

+200
-2
lines changed

2 files changed

+200
-2
lines changed

src/west/app/project.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,14 @@ 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 (regardless of whether those projects are active or inactive).
591+
This is similar to `git status` for untracked files. The
592+
output format is relative to the current working directory and is
593+
stable and suitable as input for scripting.
594+
587595
If the manifest file does not use imports, and all project
588596
revisions are SHAs, the --freeze and --resolve output will
589597
be identical after a "west update".
@@ -604,6 +612,9 @@ def do_add_parser(self, parser_adder):
604612
exiting with an error if there are issues''')
605613
group.add_argument('--path', action='store_true',
606614
help="print the top level manifest file's path")
615+
group.add_argument('--untracked', action='store_true',
616+
help='''print all files and directories not managed or
617+
tracked by west''')
607618

608619
group = parser.add_argument_group('options for --resolve and --freeze')
609620
group.add_argument('-o', '--out',
@@ -624,6 +635,8 @@ def do_run(self, args, user_args):
624635
elif args.freeze:
625636
self._die_if_manifest_project_filter('freeze')
626637
self._dump(args, manifest.as_frozen_yaml(**dump_kwargs))
638+
elif args.untracked:
639+
self._untracked()
627640
elif args.path:
628641
self.inf(manifest.path)
629642
else:
@@ -640,6 +653,70 @@ def _die_if_manifest_project_filter(self, action):
640653
'the manifest while projects are made inactive by the '
641654
'project filter.')
642655

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

@@ -1747,7 +1827,8 @@ def do_run(self, args, user_args):
17471827
17481828
To do the same with:
17491829
1750-
- git grep --untracked: west grep --untracked foo
1830+
- git grep --untracked: west grep --untracked foo (see also west manifest --untracked
1831+
for finding untracked files outside the project directories)
17511832
- ripgrep: west grep --tool ripgrep foo
17521833
- grep --recursive: west grep --tool grep foo
17531834

tests/test_project.py

Lines changed: 117 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,122 @@ 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+
# Add a file to see it's displayed correctly as untracked
277+
(topdir / "file.txt").touch()
278+
check(['dir', 'file.txt', 'unt'])
279+
(topdir / 'subdir' / "z.py").touch()
280+
check(['dir', 'file.txt', str(Path('subdir/z.py')), 'unt'])
281+
282+
(topdir / "subdir" / "new").mkdir()
283+
(topdir / "subdir" / "new" / "afile.txt").touch()
284+
check(['dir', 'file.txt', str(Path('subdir/new')),
285+
str(Path('subdir/z.py')), 'unt'])
286+
287+
# Check relative paths
288+
check([str(Path('../dir')), str(Path('../file.txt')),
289+
str(Path('../subdir/new')), str(Path('../subdir/z.py')), '.'],
290+
cwd=str(Path("unt/")))
291+
292+
# Add a file to an existing project, ignored by --untracked
293+
(topdir / "net-tools" / "test_manifest_untracked.file").touch()
294+
check(['dir', 'file.txt', str(Path('subdir/new')), str(Path('subdir/z.py')),
295+
'unt'])
296+
297+
kconfiglib = Path(topdir / "subdir" / "Kconfiglib")
298+
# Same but with an inactive project
299+
(kconfiglib / "test_manifest_untracked.file").touch()
300+
check(['dir', 'file.txt', str(Path('subdir/new')), str(Path('subdir/z.py')),
301+
'unt'])
302+
303+
# Copy (via clone) a full Git repo so we verify that those are also
304+
# displayed as untracked.
305+
clone(topdir / "net-tools", Path('subdir/acopy'))
306+
clone(topdir / "net-tools", Path('tmpcopy'))
307+
308+
check(['dir', 'file.txt', str(Path('subdir/acopy')), str(Path('subdir/new')),
309+
str(Path('subdir/z.py')), 'tmpcopy', 'unt'])
310+
311+
# Empty a project so it's not a Git repo anymore
312+
(topdir / "net-tools" / ".git").rename(topdir / "net-tools" / "former-git")
313+
# Should make no difference
314+
check(['dir', 'file.txt', str(Path('subdir/acopy')), str(Path('subdir/new')),
315+
str(Path('subdir/z.py')), 'tmpcopy', 'unt'])
316+
317+
# Same with an inactive project
318+
(kconfiglib / ".git").rename(kconfiglib / "former-git")
319+
# Should make no difference
320+
check(['dir', 'file.txt', str(Path('subdir/acopy')), str(Path('subdir/new')),
321+
str(Path('subdir/z.py')), 'tmpcopy', 'unt'])
322+
323+
# Even if we make the whole inactive project disappear it should make no
324+
# difference at all, except that the renamed dir will show up.
325+
(kconfiglib).rename(topdir / "subdir" / "other")
326+
check(['dir', 'file.txt', str(Path('subdir/acopy')), str(Path('subdir/new')),
327+
str(Path('subdir/other')), str(Path('subdir/z.py')), 'tmpcopy', 'unt'])
328+
329+
@pytest.mark.skipif(sys.platform.startswith("win"), reason="symbolic links not tested on Windows")
330+
def test_manifest_untracked_with_symlinks(west_update_tmpdir):
331+
332+
def check(expected, cwd=None):
333+
out_lines = cmd("manifest --untracked", cwd=cwd).splitlines()
334+
assert out_lines == expected
335+
336+
# Disable Kconfiglib to have an inactive project
337+
cmd('config manifest.group-filter -- -Kconfiglib-group')
338+
339+
# Ensure that Kconfiglib is inactive
340+
projs = cmd('list -f {name}').splitlines()
341+
assert projs == ['manifest', 'tagged_repo', 'net-tools']
342+
343+
# Create a folder symlink
344+
Path('asl').symlink_to(Path('subdir/Kconfiglib'))
345+
# Create another one
346+
Path('anothersl').symlink_to(Path('../'))
347+
# Yet another one
348+
Path('subdir/yetanothersl').symlink_to(Path('tagged_repo'))
349+
# check that symlinks are displayed like any other regular untracked file or
350+
# directory
351+
check(['anothersl', 'asl', str(Path('subdir/yetanothersl'))])
352+
353+
# File symlink tests, should all be displayed as untracked as well
354+
Path('filesl.yml').symlink_to(Path('zephyr/west.yml'))
355+
Path('subdir/afsl.py').symlink_to(Path('net-tools/scripts/test.py'))
356+
check(['anothersl', 'asl', 'filesl.yml', str(Path('subdir/afsl.py')),
357+
str(Path('subdir/yetanothersl'))])
358+
242359
def test_manifest_freeze(west_update_tmpdir):
243360
# We should be able to freeze manifests.
244361
actual = cmd('manifest --freeze').splitlines()

0 commit comments

Comments
 (0)