mud-git 1.1.6__tar.gz → 1.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {mud_git-1.1.6 → mud_git-1.1.7}/.github/workflows/publish-aur.yaml +26 -1
  2. mud_git-1.1.7/.github/workflows/publish-pypi.yaml +59 -0
  3. mud_git-1.1.7/.github/workflows/test.yaml +38 -0
  4. {mud_git-1.1.6 → mud_git-1.1.7}/.gitignore +3 -0
  5. mud_git-1.1.7/AGENTS.md +70 -0
  6. {mud_git-1.1.6 → mud_git-1.1.7}/PKG-INFO +4 -2
  7. {mud_git-1.1.6 → mud_git-1.1.7}/README.md +1 -1
  8. {mud_git-1.1.6 → mud_git-1.1.7}/pyproject.toml +8 -0
  9. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/app.py +5 -2
  10. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/config.py +6 -2
  11. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/runner.py +3 -4
  12. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/styles.py +0 -1
  13. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud_git.egg-info/PKG-INFO +4 -2
  14. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud_git.egg-info/SOURCES.txt +11 -1
  15. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud_git.egg-info/requires.txt +3 -0
  16. mud_git-1.1.7/src/mud_git.egg-info/scm_file_list.json +32 -0
  17. mud_git-1.1.7/src/mud_git.egg-info/scm_version.json +8 -0
  18. mud_git-1.1.7/tests/conftest.py +29 -0
  19. mud_git-1.1.7/tests/helpers.py +102 -0
  20. mud_git-1.1.7/tests/test_config.py +93 -0
  21. mud_git-1.1.7/tests/test_display.py +71 -0
  22. mud_git-1.1.7/tests/test_filters.py +57 -0
  23. mud_git-1.1.7/tests/test_run.py +55 -0
  24. mud_git-1.1.7/tests/test_states.py +86 -0
  25. mud_git-1.1.6/.github/workflows/publish-pypi.yaml +0 -31
  26. mud_git-1.1.6/.github/workflows/test.yaml +0 -110
  27. {mud_git-1.1.6 → mud_git-1.1.7}/.editorconfig +0 -0
  28. {mud_git-1.1.6 → mud_git-1.1.7}/LICENSE +0 -0
  29. {mud_git-1.1.6 → mud_git-1.1.7}/PKGBUILD +0 -0
  30. {mud_git-1.1.6 → mud_git-1.1.7}/img.png +0 -0
  31. {mud_git-1.1.6 → mud_git-1.1.7}/requirements.txt +0 -0
  32. {mud_git-1.1.6 → mud_git-1.1.7}/setup.cfg +0 -0
  33. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/__init__.py +0 -0
  34. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/__main__.py +0 -0
  35. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/commands.py +0 -0
  36. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/settings.py +0 -0
  37. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud/utils.py +0 -0
  38. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud_git.egg-info/dependency_links.txt +0 -0
  39. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud_git.egg-info/entry_points.txt +0 -0
  40. {mud_git-1.1.6 → mud_git-1.1.7}/src/mud_git.egg-info/top_level.txt +0 -0
@@ -6,11 +6,36 @@ on:
6
6
  workflow_dispatch:
7
7
 
8
8
  jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Set up Python 3.13
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.13"
18
+
19
+ - name: Configure git
20
+ run: |
21
+ git config --global user.name "Test User"
22
+ git config --global user.email "test@example.com"
23
+
24
+ - name: Install dependencies and build
25
+ run: |
26
+ pip install -r requirements.txt pytest build
27
+ python -m build
28
+ pip install dist/*.whl
29
+
30
+ - name: Run tests
31
+ run: pytest -v
32
+
9
33
  aur-publish:
34
+ needs: [test]
10
35
  runs-on: ubuntu-latest
11
36
  steps:
12
37
  - name: Checkout repository
13
- uses: actions/checkout@v2
38
+ uses: actions/checkout@v4
14
39
  with:
15
40
  fetch-depth: 0
16
41
 
@@ -0,0 +1,59 @@
1
+ name: Upload Python Package
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python 3.13
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.13"
21
+
22
+ - name: Configure git
23
+ run: |
24
+ git config --global user.name "Test User"
25
+ git config --global user.email "test@example.com"
26
+
27
+ - name: Install dependencies and build
28
+ run: |
29
+ pip install -r requirements.txt pytest build
30
+ python -m build
31
+ pip install dist/*.whl
32
+
33
+ - name: Run tests
34
+ run: pytest -v
35
+
36
+ deploy:
37
+ needs: [test]
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+
42
+ - name: Set up Python
43
+ uses: actions/setup-python@v5
44
+ with:
45
+ python-version: '3.x'
46
+
47
+ - name: Install dependencies
48
+ run: |
49
+ python -m pip install --upgrade pip
50
+ pip install build
51
+
52
+ - name: Build package
53
+ run: python -m build
54
+
55
+ - name: Publish package
56
+ uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
57
+ with:
58
+ user: __token__
59
+ password: ${{ secrets.TWINE_PAT }}
@@ -0,0 +1,38 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '*'
7
+ pull_request:
8
+ branches:
9
+ - master
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ test:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Python 3.13
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.13"
25
+
26
+ - name: Configure git
27
+ run: |
28
+ git config --global user.name "Test User"
29
+ git config --global user.email "test@example.com"
30
+
31
+ - name: Install dependencies and build
32
+ run: |
33
+ pip install -r requirements.txt pytest build
34
+ python -m build
35
+ pip install dist/*.whl
36
+
37
+ - name: Run tests
38
+ run: pytest -v
@@ -159,3 +159,6 @@ cython_debug/
159
159
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
160
  .idea/
161
161
  .run/
162
+
163
+ # Mac OS
164
+ *.DS_Store
@@ -0,0 +1,70 @@
1
+ # AI Agent Instructions
2
+
3
+ ## What `mud` does
4
+
5
+ `mud` runs git commands (and arbitrary shell commands) across multiple repositories simultaneously. It reads a `.mudconfig` TSV file listing repo paths and optional labels, then dispatches to every matching repo with filtering, async execution, and rich terminal output.
6
+
7
+ For commands, flags, and settings refer to `README.md` — it is the source of truth for user-facing behaviour. For runtime dependencies refer to `requirements.txt`.
8
+
9
+ ## Build and test
10
+
11
+ ```sh
12
+ pip install -e ".[dev]" # install with dev deps
13
+ python -m build # build wheel
14
+ pytest # run tests (verbose by default)
15
+ pytest tests/test_run.py # run a single file
16
+ ```
17
+
18
+ ## Entry point
19
+
20
+ `mud` CLI → `mud:run` in `src/mud/__init__.py` → `App` in `src/mud/app.py`
21
+
22
+ ## Module map
23
+
24
+ | File | Purpose |
25
+ |---|---|
26
+ | `src/mud/__init__.py` | Entry point; initialises `Settings`, creates `App`, calls `run()` |
27
+ | `src/mud/app.py` | CLI dispatch; parses args, applies filters, calls `Runner` |
28
+ | `src/mud/runner.py` | All display commands and execution modes |
29
+ | `src/mud/config.py` | `.mudconfig` TSV read/write; `init`, `add`, `remove`, `prune` |
30
+ | `src/mud/settings.py` | `~/.config/mud/settings.ini` read/write |
31
+ | `src/mud/commands.py` | Constants for every command name and filter flag prefix |
32
+ | `src/mud/styles.py` | ANSI escape codes and Nerd Font glyphs |
33
+ | `src/mud/utils.py` | Shared helpers: table creation, error printing, configure wizard |
34
+
35
+ ## Architecture notes
36
+
37
+ **Global settings instance** — `utils.settings` is a single `Settings` object created in `__init__.run()` and accessed across all modules.
38
+
39
+ **Command dispatch** — `App.run()` routes to either a native `Runner` method (matched against constants in `commands.py`) or a shell pass-through. The `--` separator and `-c=<cmd>` flag both reach the shell path.
40
+
41
+ **Execution modes** — three modes controlled by `run_async` + `run_table` settings (toggled by `-a` / `-t` flags): sequential, async streamed, async live-table.
42
+
43
+ **Filter chain** — `App._filter_with_arguments()` applies up to eight filters in sequence (ignore label, include/exclude label, include/exclude branch, name substring, modified, diverged). Each step removes non-matching repos.
44
+
45
+ **Nerd Fonts** — every glyph in `styles.GLYPHS` has an ASCII fallback. `utils.glyphs(key)` selects between them based on the `nerd_fonts` setting, so mud works with or without a patched font.
46
+
47
+ ## Tests
48
+
49
+ Tests are black-box CLI tests — each runs `python -m mud` as a subprocess against real git repos in a temporary directory.
50
+
51
+ | File | Covers |
52
+ |---|---|
53
+ | `tests/test_config.py` | `init`, `add`, `remove`, `prune` |
54
+ | `tests/test_display.py` | `status`, `info`, `log`, `labels`, `branches`, `tags` |
55
+ | `tests/test_run.py` | Execution modes and flags |
56
+ | `tests/test_filters.py` | `-l=`, `-L=`, `-b=`, `-B=`, `-n=` filter flags |
57
+ | `tests/test_states.py` | Edge-case repo states (unborn, detached, rebasing) |
58
+
59
+ ## Knowledge base update rule
60
+ After editing any file under `src/mud/`, update the affected sections of this file before finishing the task.
61
+ `README.md` is the source of truth for user-facing behaviour — if commands, flags, or settings changed, update the relevant table rows there. Table edits only — no new prose.
62
+
63
+ # Git
64
+
65
+ ## Branching
66
+ - All new features should have `feature/` prefix
67
+ - Bugfixes are using `bugfix/` prefix
68
+
69
+ ## Tags
70
+ Tags format is vX.Y.Z
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mud-git
3
- Version: 1.1.6
3
+ Version: 1.1.7
4
4
  Summary: Multi repository git utility. Manage multiple git-repositories simultaneously.
5
5
  Author-email: Jasur Sadikov <jasur@sadikoff.com>
6
6
  License-Expression: MIT
@@ -11,6 +11,8 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: prettytable
13
13
  Requires-Dist: pygit2
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
14
16
  Dynamic: license-file
15
17
 
16
18
  # mud
@@ -32,7 +34,7 @@ pip install mud-git
32
34
  ```
33
35
  **ArchLinux**
34
36
  ```bash
35
- paru -S mud-git
37
+ yay -S mud-git
36
38
  ```
37
39
 
38
40
  For requirements check [requirements.txt](requirements.txt).
@@ -17,7 +17,7 @@ pip install mud-git
17
17
  ```
18
18
  **ArchLinux**
19
19
  ```bash
20
- paru -S mud-git
20
+ yay -S mud-git
21
21
  ```
22
22
 
23
23
  For requirements check [requirements.txt](requirements.txt).
@@ -22,6 +22,14 @@ local_scheme = "no-local-version"
22
22
  [tool.hatch.build.targets.wheel]
23
23
  packages = ["src/mud"]
24
24
 
25
+ [project.optional-dependencies]
26
+ dev = ["pytest"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ pythonpath = ["tests"]
31
+ addopts = "-v"
32
+
25
33
  [project.scripts]
26
34
  mud = "mud:run"
27
35
 
@@ -35,7 +35,10 @@ class App:
35
35
 
36
36
  @staticmethod
37
37
  def _create_parser() -> ArgumentParser:
38
- parser = ArgumentParser(description=f'mud allows you to run commands in multiple repositories.')
38
+ parser = ArgumentParser(
39
+ description=f'mud allows you to run commands in multiple repositories.',
40
+ usage='%(prog)s [OPTIONS] [COMMAND] ...',
41
+ )
39
42
  subparsers = parser.add_subparsers(dest='command')
40
43
 
41
44
  subparsers.add_parser(LOG[0], aliases=LOG[1:], help='Displays log of latest commit messages for all repositories in a table view.')
@@ -130,7 +133,7 @@ class App:
130
133
 
131
134
  # Handling commands
132
135
  if native_command:
133
- args = self.parser.parse_args()
136
+ args, _ = self.parser.parse_known_args()
134
137
 
135
138
  if args.command in INIT + ADD + REMOVE + PRUNE + GET_CONFIG:
136
139
  if args.command in GET_CONFIG:
@@ -41,8 +41,10 @@ class Config:
41
41
 
42
42
  for path, labels in self.data.items():
43
43
  valid_labels = [label for label in labels if _filter_labels(label)]
44
- formatted_labels = ','.join(valid_labels) if valid_labels else ''
45
- writer.writerow([path, formatted_labels])
44
+ if valid_labels:
45
+ writer.writerow([path, ','.join(valid_labels)])
46
+ else:
47
+ writer.writerow([path])
46
48
 
47
49
  def load(self, file_path: str) -> None:
48
50
  self.data = {}
@@ -108,6 +110,8 @@ class Config:
108
110
 
109
111
  config_path = os.path.join(config_dir, utils.CONFIG_FILE_NAME)
110
112
  path = os.path.relpath(current_path, config_path)
113
+ if path is not None:
114
+ path = path.rstrip('/')
111
115
  if path is None:
112
116
  path = label
113
117
  label = None
@@ -413,10 +413,9 @@ class Runner:
413
413
 
414
414
  @staticmethod
415
415
  def _print_process_header(path: str, command: str, failed: bool, code: int) -> None:
416
- command = f'{BKG_WHITE}{BLACK}{glyphs('space')}{glyphs('terminal')} {BOLD}{command} {END_BOLD}{WHITE}{RESET}'
417
- code = f'{WHITE}{BKG_RED if failed else BKG_GREEN}{glyphs(')')} {glyphs('failed') if failed else glyphs('finished')} {f'{BOLD}{code}' if failed else ''}{glyphs('space')}{RESET}'
418
- path = f'{BKG_BLACK}{RED if failed else GREEN}{glyphs(')')}{RESET}{BKG_BLACK}{glyphs('space')}{WHITE}{glyphs('directory')}{END_DIM} {Runner._get_formatted_path(path)}{BKG_BLACK} {RESET}{BLACK}{glyphs(')')}{RESET}'
419
- print(f'{command}{code}{path}')
416
+ path = f'{BKG_BLACK}{BRIGHT_WHITE} {Runner._get_formatted_path(path)} {RESET}{BLACK}{BKG_RED if failed else BKG_GREEN}{glyphs(')')}{RESET}'
417
+ code = f'{BKG_RED if failed else BKG_GREEN} {BRIGHT_WHITE}{command}{f':{BOLD}{code}' if failed else ''} {RESET}{RED if failed else GREEN}{glyphs(')')}{RESET}'
418
+ print(f'{path}{code}')
420
419
 
421
420
  @staticmethod
422
421
  def _get_formatted_path(path: str, file_system: bool = True, color: str = '') -> str:
@@ -88,7 +88,6 @@ GLYPHS = {
88
88
  'labels': ['\uf041', ''],
89
89
  'tag': ['\uf02b', '>'],
90
90
  'tags': ['\uf02c', ''],
91
- 'terminal': ['\ue795', ''],
92
91
  'stash': ['\uf1c0', ''],
93
92
  'directory': ['\uf4d4', ''],
94
93
  '(': ['\uE0B2', ''],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mud-git
3
- Version: 1.1.6
3
+ Version: 1.1.7
4
4
  Summary: Multi repository git utility. Manage multiple git-repositories simultaneously.
5
5
  Author-email: Jasur Sadikov <jasur@sadikoff.com>
6
6
  License-Expression: MIT
@@ -11,6 +11,8 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: prettytable
13
13
  Requires-Dist: pygit2
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
14
16
  Dynamic: license-file
15
17
 
16
18
  # mud
@@ -32,7 +34,7 @@ pip install mud-git
32
34
  ```
33
35
  **ArchLinux**
34
36
  ```bash
35
- paru -S mud-git
37
+ yay -S mud-git
36
38
  ```
37
39
 
38
40
  For requirements check [requirements.txt](requirements.txt).
@@ -1,5 +1,6 @@
1
1
  .editorconfig
2
2
  .gitignore
3
+ AGENTS.md
3
4
  LICENSE
4
5
  PKGBUILD
5
6
  README.md
@@ -23,4 +24,13 @@ src/mud_git.egg-info/SOURCES.txt
23
24
  src/mud_git.egg-info/dependency_links.txt
24
25
  src/mud_git.egg-info/entry_points.txt
25
26
  src/mud_git.egg-info/requires.txt
26
- src/mud_git.egg-info/top_level.txt
27
+ src/mud_git.egg-info/scm_file_list.json
28
+ src/mud_git.egg-info/scm_version.json
29
+ src/mud_git.egg-info/top_level.txt
30
+ tests/conftest.py
31
+ tests/helpers.py
32
+ tests/test_config.py
33
+ tests/test_display.py
34
+ tests/test_filters.py
35
+ tests/test_run.py
36
+ tests/test_states.py
@@ -1,2 +1,5 @@
1
1
  prettytable
2
2
  pygit2
3
+
4
+ [dev]
5
+ pytest
@@ -0,0 +1,32 @@
1
+ {
2
+ "files": [
3
+ ".editorconfig",
4
+ ".github/workflows/publish-aur.yaml",
5
+ ".github/workflows/publish-pypi.yaml",
6
+ ".github/workflows/test.yaml",
7
+ ".gitignore",
8
+ "AGENTS.md",
9
+ "LICENSE",
10
+ "PKGBUILD",
11
+ "README.md",
12
+ "img.png",
13
+ "pyproject.toml",
14
+ "requirements.txt",
15
+ "src/mud/__init__.py",
16
+ "src/mud/__main__.py",
17
+ "src/mud/app.py",
18
+ "src/mud/commands.py",
19
+ "src/mud/config.py",
20
+ "src/mud/runner.py",
21
+ "src/mud/settings.py",
22
+ "src/mud/styles.py",
23
+ "src/mud/utils.py",
24
+ "tests/conftest.py",
25
+ "tests/helpers.py",
26
+ "tests/test_config.py",
27
+ "tests/test_display.py",
28
+ "tests/test_filters.py",
29
+ "tests/test_run.py",
30
+ "tests/test_states.py"
31
+ ]
32
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.1.7",
3
+ "distance": 0,
4
+ "node": "ge2409c646f484a43da494781859f961bb3c79713",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-08-16"
8
+ }
@@ -0,0 +1,29 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from helpers import make_git_repo
4
+
5
+
6
+ @pytest.fixture
7
+ def home(tmp_path: Path) -> Path:
8
+ """An isolated home directory so the mud subprocess gets clean default settings."""
9
+ h = tmp_path / "home"
10
+ h.mkdir()
11
+ return h
12
+
13
+
14
+ @pytest.fixture
15
+ def repos(tmp_path: Path) -> Path:
16
+ """Two git repos (repo_a, repo_b) with a .mudconfig in tmp_path."""
17
+ make_git_repo(tmp_path / "repo_a")
18
+ make_git_repo(tmp_path / "repo_b")
19
+ (tmp_path / ".mudconfig").write_text("repo_a\t\nrepo_b\t\n")
20
+ return tmp_path
21
+
22
+
23
+ @pytest.fixture
24
+ def repos_labeled(tmp_path: Path) -> Path:
25
+ """Same as repos but each repo has a distinct label."""
26
+ make_git_repo(tmp_path / "repo_a")
27
+ make_git_repo(tmp_path / "repo_b")
28
+ (tmp_path / ".mudconfig").write_text("repo_a\tlabel_a\nrepo_b\tlabel_b\n")
29
+ return tmp_path
@@ -0,0 +1,102 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Internal helper — runs a shell command inside a directory
9
+ # ---------------------------------------------------------------------------
10
+
11
+ def _run(cmd: str, cwd: Path, check: bool = True) -> subprocess.CompletedProcess:
12
+ return subprocess.run(cmd, cwd=cwd, shell=True, capture_output=True, text=True, check=check)
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Repo factory helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def make_git_repo(path: Path) -> None:
20
+ """A normal repo: initialised, one commit, on the default branch."""
21
+ path.mkdir(parents=True, exist_ok=True)
22
+ (path / "README.md").write_text(f"# {path.name}\n")
23
+ _run("git init", path)
24
+ _run("git config user.name 'Test User'", path)
25
+ _run("git config user.email 'test@example.com'", path)
26
+ _run("git add .", path)
27
+ _run("git commit -m 'Initial commit'", path)
28
+
29
+
30
+ def make_empty_repo(path: Path) -> None:
31
+ """A repo that has been initialised but has no commits (HEAD is unborn)."""
32
+ path.mkdir(parents=True, exist_ok=True)
33
+ _run("git init", path)
34
+ _run("git config user.name 'Test User'", path)
35
+ _run("git config user.email 'test@example.com'", path)
36
+
37
+
38
+ def make_detached_repo(path: Path) -> None:
39
+ """A repo whose HEAD is detached at a plain commit (not a tag)."""
40
+ make_git_repo(path)
41
+ commit = _run("git rev-parse HEAD", path).stdout.strip()
42
+ _run(f"git checkout {commit}", path)
43
+
44
+
45
+ def make_tagged_repo(path: Path) -> None:
46
+ """A repo checked out at an annotated tag, leaving HEAD detached."""
47
+ make_git_repo(path)
48
+ _run("git tag v1.0", path)
49
+ _run("git checkout v1.0", path)
50
+
51
+
52
+ def make_rebasing_repo(path: Path) -> None:
53
+ """A repo that is mid-rebase because of a conflict.
54
+
55
+ Creates two branches with a conflicting edit to the same file,
56
+ then starts a rebase on the feature branch so the repo is left in
57
+ the 'rebasing' state (HEAD detached, .git/REBASE_HEAD present).
58
+ """
59
+ path.mkdir(parents=True, exist_ok=True)
60
+ (path / "file.txt").write_text("original\n")
61
+ _run("git init", path)
62
+ _run("git config user.name 'Test User'", path)
63
+ _run("git config user.email 'test@example.com'", path)
64
+ _run("git add .", path)
65
+ _run("git commit -m 'initial'", path)
66
+
67
+ main_branch = _run("git branch --show-current", path).stdout.strip()
68
+
69
+ _run("git checkout -b feature", path)
70
+ (path / "file.txt").write_text("feature change\n")
71
+ _run("git add .", path)
72
+ _run("git commit -m 'feature'", path)
73
+
74
+ _run(f"git checkout {main_branch}", path)
75
+ (path / "file.txt").write_text("main change\n")
76
+ _run("git add .", path)
77
+ _run("git commit -m 'main change'", path)
78
+
79
+ _run("git checkout feature", path)
80
+ # Intentionally not check=True — the rebase stops mid-way on the conflict.
81
+ _run(f"git rebase {main_branch}", path, check=False)
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # CLI runner
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def run_mud(*args: str, cwd: Path, home: Path) -> subprocess.CompletedProcess:
89
+ """Run ``python -m mud <args>`` inside *cwd* and return the result.
90
+
91
+ HOME is pointed at an empty temp directory so the subprocess gets
92
+ clean default mud settings, isolated from the developer's own machine.
93
+ """
94
+ env = os.environ.copy()
95
+ env["HOME"] = str(home)
96
+ return subprocess.run(
97
+ [sys.executable, "-m", "mud", *args],
98
+ cwd=cwd,
99
+ capture_output=True,
100
+ text=True,
101
+ env=env,
102
+ )
@@ -0,0 +1,93 @@
1
+ """
2
+ Tests for config-management commands: init, add, remove, prune.
3
+
4
+ Each test runs `mud <command>` as a real subprocess and checks the
5
+ exit code plus the contents of .mudconfig on disk.
6
+ """
7
+ from pathlib import Path
8
+ from helpers import make_git_repo, run_mud
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # mud init
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def test_init_creates_mudconfig(tmp_path: Path, home: Path):
16
+ """mud init creates .mudconfig and adds all git repos it finds."""
17
+ make_git_repo(tmp_path / "repo_a")
18
+ make_git_repo(tmp_path / "repo_b")
19
+
20
+ result = run_mud("init", cwd=tmp_path, home=home)
21
+
22
+ assert result.returncode == 0
23
+ mudconfig = (tmp_path / ".mudconfig").read_text()
24
+ assert "repo_a" in mudconfig
25
+ assert "repo_b" in mudconfig
26
+
27
+
28
+ def test_init_is_idempotent(tmp_path: Path, home: Path):
29
+ """Running mud init twice does not duplicate repos in .mudconfig."""
30
+ make_git_repo(tmp_path / "repo_a")
31
+
32
+ run_mud("init", cwd=tmp_path, home=home)
33
+ run_mud("init", cwd=tmp_path, home=home)
34
+
35
+ mudconfig = (tmp_path / ".mudconfig").read_text()
36
+ assert mudconfig.count("repo_a") == 1
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # mud add
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def test_add_repo(repos: Path, home: Path):
44
+ """mud add <path> registers a new repo in .mudconfig."""
45
+ make_git_repo(repos / "repo_c")
46
+
47
+ result = run_mud("add", "repo_c", cwd=repos, home=home)
48
+
49
+ assert result.returncode == 0
50
+ assert "repo_c" in (repos / ".mudconfig").read_text()
51
+
52
+
53
+ def test_add_repo_with_label(repos: Path, home: Path):
54
+ """mud add <path> <label> registers a repo with a label."""
55
+ make_git_repo(repos / "repo_c")
56
+
57
+ run_mud("add", "repo_c", "my_label", cwd=repos, home=home)
58
+
59
+ # Verify label appears in the labels command output
60
+ result = run_mud("labels", cwd=repos, home=home)
61
+ assert "my_label" in result.stdout
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # mud remove
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def test_remove_repo(repos: Path, home: Path):
69
+ """mud remove <path> drops the repo from .mudconfig."""
70
+ result = run_mud("remove", "repo_a", cwd=repos, home=home)
71
+
72
+ assert result.returncode == 0
73
+ mudconfig = (repos / ".mudconfig").read_text()
74
+ assert "repo_a" not in mudconfig
75
+ assert "repo_b" in mudconfig # sibling repo must be untouched
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # mud prune
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def test_prune_removes_missing_paths(tmp_path: Path, home: Path):
83
+ """mud prune removes entries whose paths no longer exist on disk."""
84
+ make_git_repo(tmp_path / "repo_a")
85
+ # ghost_repo is listed in .mudconfig but does not exist on disk
86
+ (tmp_path / ".mudconfig").write_text("repo_a\t\nghost_repo\t\n")
87
+
88
+ result = run_mud("prune", cwd=tmp_path, home=home)
89
+
90
+ assert result.returncode == 0
91
+ mudconfig = (tmp_path / ".mudconfig").read_text()
92
+ assert "repo_a" in mudconfig
93
+ assert "ghost_repo" not in mudconfig
@@ -0,0 +1,71 @@
1
+ """
2
+ Tests for read-only display commands: status, info, log, labels, branches, tags.
3
+
4
+ Each command should exit 0 and include the repo directory names in its output.
5
+ Output contains ANSI colour codes but plain text like "repo_a" is always present.
6
+ """
7
+ from pathlib import Path
8
+ from helpers import run_mud
9
+
10
+
11
+ def test_no_args_shows_help(tmp_path: Path, home: Path):
12
+ """mud with no arguments must print help without raising an exception (issue #90).
13
+
14
+ This crashed on Python 3.12 with an AssertionError inside argparse._format_usage
15
+ because the auto-generated usage string was too long and triggered a buggy
16
+ regex-based wrapping assertion. The fix is to supply a custom usage= string
17
+ to ArgumentParser so that assertion is never reached.
18
+ """
19
+ result = run_mud(cwd=tmp_path, home=home)
20
+ assert result.returncode == 0, f"mud with no args exited {result.returncode}:\n{result.stderr}"
21
+ # Should mention key commands in the help output
22
+ assert "mud" in result.stdout or "mud" in result.stderr
23
+
24
+
25
+ def test_status(repos: Path, home: Path):
26
+ result = run_mud("status", cwd=repos, home=home)
27
+ assert result.returncode == 0
28
+ assert "repo_a" in result.stdout
29
+ assert "repo_b" in result.stdout
30
+
31
+
32
+ def test_info(repos: Path, home: Path):
33
+ result = run_mud("info", cwd=repos, home=home)
34
+ assert result.returncode == 0
35
+ assert "repo_a" in result.stdout
36
+ assert "repo_b" in result.stdout
37
+
38
+
39
+ def test_log(repos: Path, home: Path):
40
+ result = run_mud("log", cwd=repos, home=home)
41
+ assert result.returncode == 0
42
+ assert "repo_a" in result.stdout
43
+ assert "repo_b" in result.stdout
44
+
45
+
46
+ def test_labels(repos: Path, home: Path):
47
+ result = run_mud("labels", cwd=repos, home=home)
48
+ assert result.returncode == 0
49
+ assert "repo_a" in result.stdout
50
+ assert "repo_b" in result.stdout
51
+
52
+
53
+ def test_labels_shows_label_values(repos_labeled: Path, home: Path):
54
+ result = run_mud("labels", cwd=repos_labeled, home=home)
55
+ assert result.returncode == 0
56
+ assert "label_a" in result.stdout
57
+ assert "label_b" in result.stdout
58
+
59
+
60
+ def test_branches(repos: Path, home: Path):
61
+ result = run_mud("branches", cwd=repos, home=home)
62
+ assert result.returncode == 0
63
+ assert "repo_a" in result.stdout
64
+ assert "repo_b" in result.stdout
65
+
66
+
67
+ def test_tags(repos: Path, home: Path):
68
+ result = run_mud("tags", cwd=repos, home=home)
69
+ assert result.returncode == 0
70
+ assert "repo_a" in result.stdout
71
+ assert "repo_b" in result.stdout
@@ -0,0 +1,57 @@
1
+ """
2
+ Tests for filter flags: -l= (include label), -L= (exclude label), -b= (branch).
3
+
4
+ All shell commands here use -a to run in ordered mode so the output is
5
+ straightforward to assert on.
6
+ """
7
+ import subprocess
8
+ from pathlib import Path
9
+ from helpers import run_mud
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Label filters
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def test_label_include_filter(repos_labeled: Path, home: Path):
17
+ """-l=<label> restricts execution to repos that carry that label."""
18
+ result = run_mud("-a", "-l=label_a", "echo", "hello", cwd=repos_labeled, home=home)
19
+ assert result.returncode == 0
20
+ assert "repo_a" in result.stdout
21
+ assert "repo_b" not in result.stdout
22
+
23
+
24
+ def test_label_exclude_filter(repos_labeled: Path, home: Path):
25
+ """-L=<label> skips repos that carry that label."""
26
+ result = run_mud("-a", "-L=label_a", "echo", "hello", cwd=repos_labeled, home=home)
27
+ assert result.returncode == 0
28
+ assert "repo_b" in result.stdout
29
+ assert "repo_a" not in result.stdout
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Branch filter
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def test_name_filter(repos: Path, home: Path):
37
+ """-n=<string> restricts execution to repos whose path contains that string."""
38
+ result = run_mud("-a", "-n=repo_a", "echo", "hello", cwd=repos, home=home)
39
+ assert result.returncode == 0
40
+ assert "repo_a" in result.stdout
41
+ assert "repo_b" not in result.stdout
42
+
43
+
44
+ def test_branch_filter(repos_labeled: Path, home: Path):
45
+ """-b=<branch> restricts execution to repos currently on that branch."""
46
+ # Put repo_a on a feature branch; repo_b stays on the default branch.
47
+ subprocess.run(
48
+ ["git", "checkout", "-b", "feature"],
49
+ cwd=repos_labeled / "repo_a",
50
+ check=True,
51
+ capture_output=True,
52
+ )
53
+
54
+ result = run_mud("-a", "-b=feature", "echo", "hello", cwd=repos_labeled, home=home)
55
+ assert result.returncode == 0
56
+ assert "repo_a" in result.stdout
57
+ assert "repo_b" not in result.stdout
@@ -0,0 +1,55 @@
1
+ """
2
+ Tests for shell command execution modes.
3
+
4
+ mud has three execution modes controlled by the -a (async) and -t (table) flags.
5
+ Default settings have both enabled, so:
6
+
7
+ mud <cmd> -> async + table (run_async_table_view)
8
+ mud -t <cmd> -> async, no table (run_async)
9
+ mud -a <cmd> -> no async, no table (run_ordered) <- most readable output
10
+
11
+ We test that all modes exit 0 and that the command actually ran in each repo.
12
+ """
13
+ from pathlib import Path
14
+ from helpers import run_mud
15
+
16
+
17
+ def test_run_ordered(repos: Path, home: Path):
18
+ """mud -a <cmd> runs sequentially, one repo at a time."""
19
+ result = run_mud("-a", "echo", "hello", cwd=repos, home=home)
20
+ assert result.returncode == 0
21
+ assert "hello" in result.stdout
22
+
23
+
24
+ def test_run_async(repos: Path, home: Path):
25
+ """mud -t <cmd> runs all repos concurrently without the table view."""
26
+ result = run_mud("-t", "echo", "hello", cwd=repos, home=home)
27
+ assert result.returncode == 0
28
+ assert "hello" in result.stdout
29
+
30
+
31
+ def test_run_async_table_view(repos: Path, home: Path):
32
+ """mud <cmd> uses the live table view by default."""
33
+ result = run_mud("echo", "hello", cwd=repos, home=home)
34
+ assert result.returncode == 0
35
+
36
+
37
+ def test_run_explicit_command_flag(repos: Path, home: Path):
38
+ """-c= lets you pass a command that contains spaces or special characters."""
39
+ result = run_mud("-a", "-c=echo hello", cwd=repos, home=home)
40
+ assert result.returncode == 0
41
+ assert "hello" in result.stdout
42
+
43
+
44
+ def test_run_double_dash_separator(repos: Path, home: Path):
45
+ """mud -t -- <cmd> treats everything after -- as the literal command."""
46
+ result = run_mud("-t", "--", "echo", "hello", cwd=repos, home=home)
47
+ assert result.returncode == 0
48
+ assert "hello" in result.stdout
49
+
50
+
51
+ def test_run_command_runs_in_every_repo(repos: Path, home: Path):
52
+ """The command is executed once per repo in the config."""
53
+ result = run_mud("-a", "echo", "hello", cwd=repos, home=home)
54
+ # "hello" appears at least once per repo (the header also echoes the command name)
55
+ assert result.stdout.count("hello") >= 2
@@ -0,0 +1,86 @@
1
+ """
2
+ Tests for display commands against repos in unusual states.
3
+
4
+ mud must handle every state gracefully — exit 0, no crashes.
5
+ The six display commands are run against four edge-case repo states:
6
+
7
+ no commits — git init only, HEAD is unborn
8
+ detached — HEAD points at a commit hash, not a branch
9
+ on tag — HEAD detached at a tagged commit
10
+ rebasing — rebase stopped mid-way due to a conflict
11
+ """
12
+ import pytest
13
+ from pathlib import Path
14
+ from helpers import (
15
+ run_mud,
16
+ make_empty_repo,
17
+ make_detached_repo,
18
+ make_tagged_repo,
19
+ make_rebasing_repo,
20
+ )
21
+
22
+ DISPLAY_COMMANDS = ["status", "info", "log", "labels", "branches", "tags"]
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Fixtures — one per repo state
27
+ # ---------------------------------------------------------------------------
28
+
29
+ @pytest.fixture
30
+ def repo_no_commits(tmp_path: Path) -> Path:
31
+ make_empty_repo(tmp_path / "repo_a")
32
+ (tmp_path / ".mudconfig").write_text("repo_a\t\n")
33
+ return tmp_path
34
+
35
+
36
+ @pytest.fixture
37
+ def repo_detached(tmp_path: Path) -> Path:
38
+ make_detached_repo(tmp_path / "repo_a")
39
+ (tmp_path / ".mudconfig").write_text("repo_a\t\n")
40
+ return tmp_path
41
+
42
+
43
+ @pytest.fixture
44
+ def repo_on_tag(tmp_path: Path) -> Path:
45
+ make_tagged_repo(tmp_path / "repo_a")
46
+ (tmp_path / ".mudconfig").write_text("repo_a\t\n")
47
+ return tmp_path
48
+
49
+
50
+ @pytest.fixture
51
+ def repo_rebasing(tmp_path: Path) -> Path:
52
+ make_rebasing_repo(tmp_path / "repo_a")
53
+ (tmp_path / ".mudconfig").write_text("repo_a\t\n")
54
+ return tmp_path
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Tests — parametrised over every display command
59
+ # ---------------------------------------------------------------------------
60
+
61
+ @pytest.mark.parametrize("cmd", DISPLAY_COMMANDS)
62
+ def test_display_no_commits(repo_no_commits: Path, home: Path, cmd: str):
63
+ """Display commands must not crash on a repo with no commits."""
64
+ result = run_mud(cmd, cwd=repo_no_commits, home=home)
65
+ assert result.returncode == 0
66
+
67
+
68
+ @pytest.mark.parametrize("cmd", DISPLAY_COMMANDS)
69
+ def test_display_detached_head(repo_detached: Path, home: Path, cmd: str):
70
+ """Display commands must not crash on a repo with detached HEAD."""
71
+ result = run_mud(cmd, cwd=repo_detached, home=home)
72
+ assert result.returncode == 0
73
+
74
+
75
+ @pytest.mark.parametrize("cmd", DISPLAY_COMMANDS)
76
+ def test_display_on_tag(repo_on_tag: Path, home: Path, cmd: str):
77
+ """Display commands must not crash on a repo checked out at a tag."""
78
+ result = run_mud(cmd, cwd=repo_on_tag, home=home)
79
+ assert result.returncode == 0
80
+
81
+
82
+ @pytest.mark.parametrize("cmd", DISPLAY_COMMANDS)
83
+ def test_display_rebasing(repo_rebasing: Path, home: Path, cmd: str):
84
+ """Display commands must not crash on a repo that is mid-rebase."""
85
+ result = run_mud(cmd, cwd=repo_rebasing, home=home)
86
+ assert result.returncode == 0
@@ -1,31 +0,0 @@
1
- name: Upload Python Package
2
-
3
- on:
4
- release:
5
- types: [published]
6
- workflow_dispatch:
7
-
8
- permissions:
9
- contents: read
10
-
11
- jobs:
12
- deploy:
13
- runs-on: ubuntu-latest
14
-
15
- steps:
16
- - uses: actions/checkout@v4
17
- - name: Set up Python
18
- uses: actions/setup-python@v3
19
- with:
20
- python-version: '3.x'
21
- - name: Install dependencies
22
- run: |
23
- python -m pip install --upgrade pip
24
- pip install build
25
- - name: Build package
26
- run: python -m build
27
- - name: Publish package
28
- uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
29
- with:
30
- user: __token__
31
- password: ${{ secrets.TWINE_PAT }}
@@ -1,110 +0,0 @@
1
- name: Test
2
-
3
- on:
4
- push:
5
- branches:
6
- - '*'
7
- pull_request:
8
- branches:
9
- - master
10
- workflow_dispatch:
11
-
12
- permissions:
13
- contents: read
14
-
15
- jobs:
16
- test:
17
- runs-on: ubuntu-latest
18
- steps:
19
- - uses: actions/checkout@v4
20
- - name: Set up Python 3.13
21
- uses: actions/setup-python@v3
22
- with:
23
- python-version: "3.13"
24
- - name: Install dependencies and set up the environment
25
- run: |
26
- python -m pip install -r requirements.txt
27
- python -m pip install requests build
28
-
29
- python -m build
30
- pip install dist/*.whl
31
- - name: Create repositories
32
- run: |
33
- git config --global user.name "Jasur Sadikov"
34
- git config --global user.email "test@test.com"
35
-
36
- for i in {1..3}; do
37
- mkdir "repo_$i"
38
- cd "repo_$i"
39
- echo "repo_$i" > test.txt
40
- git init
41
- git add .
42
- git commit -m "Initial commit"
43
- cd ..
44
- done
45
- - name: Initialize
46
- run: |
47
- echo "mud init"
48
- mud init
49
- - name: Test remove by path
50
- run: |
51
- for i in {1..3}; do
52
- echo "mud remove repo_$i"
53
- mud remove repo_$i
54
- done
55
- - name: Test add with label
56
- run: |
57
- for i in {1..3}; do
58
- echo "mud add repo_$i label_$i"
59
- mud add repo_$i label_$i
60
- done
61
- echo "mud labels"
62
- - name: Test remove path
63
- run: |
64
- for i in {1..3}; do
65
- echo "mud remove repo_$i"
66
- mud remove repo_$i
67
- done
68
- echo "mud labels"
69
- - name: Test prune command
70
- run: |
71
- mud init
72
- echo "test" > .mudconfig
73
- echo "mud prune"
74
- echo -e "test1_remove\ntest2_remove" > .mudconfig
75
- mud prune
76
- - name: Add all repositories with labels
77
- run: |
78
- rm .mudconfig
79
- mud init
80
- for i in {1..3}; do
81
- mud add label_$i repo_$i
82
- done
83
- - name: Test default commands
84
- run: |
85
- cat .mudconfig
86
- echo "mud labels"
87
- mud labels
88
- echo "mud status"
89
- mud status
90
- echo "mud info"
91
- mud info
92
- echo "mud log"
93
- mud log
94
- echo "mud branch"
95
- mud branch
96
- echo "mud tags"
97
- mud tags
98
- - name: Test custom command
99
- run: |
100
- mud echo "Hello world"
101
- mud -a echo "Hello world"
102
- mud -a -t echo "Hello world"
103
- mud -t echo "Hello world"
104
- mud -t -c='echo "Hello" && echo "world"'
105
- - name: Test label filtering
106
- run: mud -l=label_1 echo "Hello world"
107
- - name: Test branch filtering
108
- run: |
109
- mud -l=label_1 git checkout -b develop
110
- mud -b=develop echo "Hello world"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes