Skip to content

Commit

Permalink
USE HARDLINKS BY DEFAULT
Browse files Browse the repository at this point in the history
You can still use symlinks via launching ammo with -s.

The reason for this change is to improve compatibility with external
tools like dyndolod, which seem to have trouble with symlinks.
  • Loading branch information
cyberrumor committed Oct 10, 2023
1 parent b80def2 commit 4c1d530
Show file tree
Hide file tree
Showing 10 changed files with 55 additions and 27 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ vanilla Disable all managed components and
```

# Technical Details
- AMMO works via creating symlinks in your game directory pointing to your mod files.
- AMMO works via creating hardlinks (or symlinks with `ammo -s`) in your game directory
pointing to your mod files.
- When you install an archive, the archive may be renamed to remove special characters.
- This will remove symlinks and empty directories from your game dir, and reinstall them whenever you commit.
- This will remove symlinks, hardlinks, and empty directories from your game dir,
and reinstall them whenever you commit.

# License
GNU General Public License v2, with the exception of some of the mock mods used for testing,
Expand Down
12 changes: 11 additions & 1 deletion ammo/ammo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@


def main():

args = sys.argv

if "-h" in args or "--help" in args:
print("Usage: ammo [options]")
print("-s --symlink Use symlinks instead of hardlinks")
sys.exit(0)

use_symlinks = "-s" in sys.argv or "--symlink" in sys.argv

# game selection
games = [game.name for game in (STEAM / "common").iterdir() if game.name in IDS]
if not games:
Expand Down Expand Up @@ -83,7 +93,7 @@ def main():
)

# Create an instance of the controller.
controller = Controller(DOWNLOADS, game)
controller = Controller(DOWNLOADS, game, use_symlinks)

# Run the UI against the controller.
ui = UI(controller)
Expand Down
9 changes: 6 additions & 3 deletions ammo/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@


class Controller:
def __init__(self, downloads_dir: Path, game: Game):
def __init__(self, downloads_dir: Path, game: Game, use_symlinks=False):
self.use_symlinks = use_symlinks
self.downloads_dir: Path = downloads_dir
self.game: Game = game
self.changes: bool = False
Expand Down Expand Up @@ -919,8 +920,10 @@ def commit(self) -> bool:
Path.mkdir(dest.parent, parents=True, exist_ok=True)
(name, src) = source
try:
dest.symlink_to(src)
# dest.hardlink_to(src)
if self.use_symlinks:
dest.symlink_to(src)
else:
dest.hardlink_to(src)
except FileExistsError:
skipped_files.append(
f"{name} skipped overwriting an unmanaged file: \
Expand Down
13 changes: 7 additions & 6 deletions test/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ class AmmoController:
Removes those folders on exit or error.
"""

def __init__(self):
def __init__(self, use_symlinks=False):
self.use_symlinks = use_symlinks
self.game = GAME
script_path = Path(__file__)
self.downloads_dir = script_path.parent / "Downloads"
Expand All @@ -43,7 +44,7 @@ def __enter__(self):
Return an instance of ammo's controller for tests to
interact with.
"""
return Controller(self.downloads_dir, self.game)
return Controller(self.downloads_dir, self.game, self.use_symlinks)

def __exit__(self, *args, **kwargs):
"""
Expand Down Expand Up @@ -109,12 +110,12 @@ def mod_extracts_files(mod_name, files):
raise FileNotFoundError(expected_file)


def mod_installs_files(mod_name, files):
def mod_installs_files(mod_name, files, use_symlinks=False):
"""
Expects the name of a file in Downloads, and a list of file paths that should
exist after installation and commit, relative to the game's directory.
"""
with AmmoController() as controller:
with AmmoController(use_symlinks) as controller:
# install the mod
mod_index_download = [i.name for i in controller.downloads].index(
mod_name + ".7z"
Expand Down Expand Up @@ -155,7 +156,7 @@ def mod_installs_files(mod_name, files):
), f"Detected lonely hard link: {expected_file}"


def fomod_selections_choose_files(mod_name, files, selections=[]):
def fomod_selections_choose_files(mod_name, files, selections=[], use_symlinks=False):
"""
Configure a fomod with flags, using default flags if unspecified.
Expand All @@ -164,7 +165,7 @@ def fomod_selections_choose_files(mod_name, files, selections=[]):
selections is a list of {"page": <page_number>, "option": <selection index>}
"""
with AmmoController() as controller:
with AmmoController(use_symlinks) as controller:
mod_index_download = [i.name for i in controller.downloads].index(
mod_name + ".7z"
)
Expand Down
12 changes: 8 additions & 4 deletions test/test_conflict_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from pathlib import Path
from common import AmmoController

import pytest

MOD_1 = "conflict_1"
MOD_2 = "conflict_2"

Expand Down Expand Up @@ -35,14 +37,15 @@ def test_duplicate_plugin():
assert len(controller.plugins) == 1


def test_conflict_resolution():
@pytest.mark.parametrize("use_symlinks", [True, False])
def test_conflict_resolution(use_symlinks):
"""
Install two mods with the same files. Verify the symlinks
point back to the mod last in the load order.
Conflicts for all files and plugins are won by a single mod.
"""
with AmmoController() as controller:
with AmmoController(use_symlinks) as controller:
# Install both mods
for mod in [MOD_1, MOD_2]:
mod_index_download = [i.name for i in controller.downloads].index(
Expand Down Expand Up @@ -97,14 +100,15 @@ def check_links(expected_game_file, expected_mod_file):
check_links(expected_game_file, expected_mod_file)


def test_conflicting_plugins_disable():
@pytest.mark.parametrize("use_symlinks", [True, False])
def test_conflicting_plugins_disable(use_symlinks):
"""
Install two mods with the same files. Disable the one that is winning the
conflict for the plugin.
Test that the plugin isn't removed from the controller's plugins.
"""
with AmmoController() as controller:
with AmmoController(use_symlinks) as controller:
# Install both mods
for mod in [MOD_1, MOD_2]:
mod_index_download = [i.name for i in controller.downloads].index(
Expand Down
11 changes: 7 additions & 4 deletions test/test_controller_instance.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
#!/usr/bin/env python3
import os
from common import AmmoController, install_everything
import pytest


def test_controller_first_launch():
@pytest.mark.parametrize("use_symlinks", [True, False])
def test_controller_first_launch(use_symlinks):
"""
Sanity check the ammo_controller fixture, that it creates
the game directory and properly removes it.
"""
with AmmoController() as controller:
with AmmoController(use_symlinks) as controller:
game_dir = controller.game.directory
assert os.path.exists(
game_dir
Expand Down Expand Up @@ -47,13 +49,14 @@ def test_controller_first_launch():
), f"ammo_dir {conf_dir} existed after the context manager closed."


def test_controller_subsequent_launch():
@pytest.mark.parametrize("use_symlinks", [True, False])
def test_controller_subsequent_launch(use_symlinks):
"""
Ensure ammo behaves correctly when launched against a game
that already has mods installed, Plugins.txt populated with
a non-default order, etc.
"""
with AmmoController() as first_launch:
with AmmoController(use_symlinks) as first_launch:
install_everything(first_launch)

# change some config to ensure it's not just alphabetic
Expand Down
6 changes: 4 additions & 2 deletions test/test_embers_xd.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
from pathlib import Path
from common import mod_installs_files
import pytest


def test_fomod_embers_xd():
@pytest.mark.parametrize("use_symlinks", [True, False])
def test_fomod_embers_xd(use_symlinks):
"""
In the past, there were some issues with Embers XD plugin becoming visible
upon activate, but not persisting through a refresh.
Expand All @@ -18,4 +20,4 @@ def test_fomod_embers_xd():
Path("Data/Embers XD - Fire Magick Add-On.esp"),
]

mod_installs_files("mock_embers_xd", files)
mod_installs_files("mock_embers_xd", files, use_symlinks)
1 change: 0 additions & 1 deletion test/test_realistic_ragdolls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
from pathlib import Path
from common import (
AmmoController,
fomod_selections_choose_files,
)

Expand Down
6 changes: 4 additions & 2 deletions test/test_script_extender.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
from pathlib import Path
from common import mod_extracts_files, mod_installs_files
import pytest

MOD = "mock_script_extender"
FILES = [
Expand All @@ -21,9 +22,10 @@ def test_install_script_extender():
mod_extracts_files(MOD, FILES)


def test_activate_script_extender():
@pytest.mark.parametrize("use_symlinks", [True, False])
def test_activate_script_extender(use_symlinks):
"""
Tests that activating a script extender causes files
to exist in expected locations.
"""
mod_installs_files(MOD, FILES)
mod_installs_files(MOD, FILES, use_symlinks)
6 changes: 4 additions & 2 deletions test/test_skyui.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
from pathlib import Path
from common import mod_extracts_files, mod_installs_files
import pytest

MOD = "mock_skyui"
EXTRACT_FILES = [
Expand All @@ -20,11 +21,12 @@ def test_install_fake_fomod():
mod_extracts_files(MOD, EXTRACT_FILES)


def test_activate_fake_fomod():
@pytest.mark.parametrize("use_symlinks", [True, False])
def test_activate_fake_fomod(use_symlinks):
"""
Tests that activating a mod that has a fomod dir but no
ModuleConfig.txt installs symlinks to the expected locations.
Notably, anything inside of the fomod dir is undesired.
"""
mod_installs_files(MOD, INSTALL_FILES)
mod_installs_files(MOD, INSTALL_FILES, use_symlinks)

0 comments on commit 4c1d530

Please sign in to comment.