247 lines
9.9 KiB
Python
247 lines
9.9 KiB
Python
"""Archiving a card reaches git (task 44), and so does every other write the
|
|||
|
|
board makes to a task file.
|
||
|
|
|
||
|
|
A move commits itself; an archive used to rename the file on disk and stop
|
||
|
|
there, leaving an uncommitted deletion of a tracked file — precisely what
|
||
|
|
sync refuses to run over. Appended agent reports had the same gap. These
|
||
|
|
cases run against a throwaway git repo, so the commits are real ones.
|
||
|
|
|
||
|
|
python3 -m unittest discover -s tests -v
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
REPO = Path(__file__).resolve().parents[1]
|
||
|
|
sys.path.insert(0, str(REPO / "manager" / "core"))
|
||
|
|
|
||
|
|
import agents # noqa: E402
|
||
|
|
import config # noqa: E402
|
||
|
|
import state # noqa: E402
|
||
|
|
import taskfiles # noqa: E402
|
||
|
|
import watch # noqa: E402
|
||
|
|
|
||
|
|
CARD = ("# 44 — A card that gets archived\n\n"
|
||
|
|
"**Status:** Backlog\n"
|
||
|
|
"**Priority:** High\n\n"
|
||
|
|
"Body text nobody should touch.\n")
|
||
|
|
FILENAME = "44-a-card.md"
|
||
|
|
MOVER = "Mover One"
|
||
|
|
|
||
|
|
|
||
|
|
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||
|
|
return subprocess.run(["git", "-C", str(cwd), *args],
|
||
|
|
capture_output=True, text=True)
|
||
|
|
|
||
|
|
|
||
|
|
class ArchiveReachesGit(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
# resolve(): macOS tempdirs sit behind the /var → /private/var
|
||
|
|
# symlink and git reports the resolved path, so absolute pathspecs
|
||
|
|
# only match if we resolve too.
|
||
|
|
tmp = Path(tempfile.mkdtemp(prefix="bench-archive-")).resolve()
|
||
|
|
self.addCleanup(shutil.rmtree, tmp, True)
|
||
|
|
self.repo = tmp / "repo"
|
||
|
|
for slug in config.STAGE_DIRS:
|
||
|
|
(self.repo / "tasks" / slug).mkdir(parents=True)
|
||
|
|
subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)],
|
||
|
|
check=True, capture_output=True)
|
||
|
|
git(self.repo, "config", "user.name", MOVER)
|
||
|
|
git(self.repo, "config", "user.email", "mover@example.com")
|
||
|
|
|
||
|
|
self.patch(TASKS=self.repo / "tasks", REPO=self.repo,
|
||
|
|
SESSIONS_DIR=tmp / "sessions", COMMIT_MOVES=True)
|
||
|
|
state.BOARD_EVENTS.clear()
|
||
|
|
state.EXPECTED_MOVES.clear()
|
||
|
|
|
||
|
|
self.write("backlog", CARD)
|
||
|
|
git(self.repo, "add", "-A")
|
||
|
|
git(self.repo, "commit", "-q", "-m", "root")
|
||
|
|
self.baseline = self.commit_count()
|
||
|
|
|
||
|
|
def patch(self, **values) -> None:
|
||
|
|
for attr, value in values.items():
|
||
|
|
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
||
|
|
setattr(config, attr, value)
|
||
|
|
|
||
|
|
# — the repo under test —
|
||
|
|
|
||
|
|
def path(self, stage: str) -> Path:
|
||
|
|
return self.repo / "tasks" / stage / FILENAME
|
||
|
|
|
||
|
|
def write(self, stage: str, text: str) -> None:
|
||
|
|
self.path(stage).write_text(text, encoding="utf-8")
|
||
|
|
|
||
|
|
def read(self, stage: str) -> str:
|
||
|
|
return self.path(stage).read_text(encoding="utf-8")
|
||
|
|
|
||
|
|
def commit_count(self) -> int:
|
||
|
|
return int(git(self.repo, "rev-list", "--count", "HEAD").stdout.strip())
|
||
|
|
|
||
|
|
def head_message(self) -> str:
|
||
|
|
return git(self.repo, "log", "-1", "--pretty=%s").stdout.strip()
|
||
|
|
|
||
|
|
def head_files(self) -> list[str]:
|
||
|
|
# --no-renames: the point is which paths the commit touched, not how
|
||
|
|
# git chooses to describe the pair.
|
||
|
|
out = git(self.repo, "show", "--name-only", "--no-renames",
|
||
|
|
"--pretty=format:", "HEAD").stdout
|
||
|
|
return sorted(line for line in out.splitlines() if line.strip())
|
||
|
|
|
||
|
|
def porcelain(self) -> str:
|
||
|
|
return git(self.repo, "status", "--porcelain").stdout
|
||
|
|
|
||
|
|
def dirty(self) -> str:
|
||
|
|
"""What sync._clean() looks at: tracked files only."""
|
||
|
|
return git(self.repo, "status", "--porcelain",
|
||
|
|
"--untracked-files=no").stdout
|
||
|
|
|
||
|
|
# — archiving —
|
||
|
|
|
||
|
|
def test_archiving_commits_the_move_out_of_the_stage(self):
|
||
|
|
taskfiles.archive_task(FILENAME, "backlog")
|
||
|
|
|
||
|
|
self.assertEqual(self.commit_count(), self.baseline + 1)
|
||
|
|
self.assertEqual(self.head_message(), f"board: 44 → archived ({MOVER})")
|
||
|
|
self.assertEqual(self.head_files(),
|
||
|
|
["tasks/archive/" + FILENAME, "tasks/backlog/" + FILENAME],
|
||
|
|
"both paths, so git records a rename not a delete and an add")
|
||
|
|
self.assertEqual(self.porcelain(), "",
|
||
|
|
"nothing is left behind for a human to find later")
|
||
|
|
self.assertIn("**Status:** Archived", self.read("archive"))
|
||
|
|
|
||
|
|
def test_the_archive_no_longer_stalls_sync(self):
|
||
|
|
"""The bug, stated the way it bit: `sync._clean()` reads
|
||
|
|
`git status --porcelain --untracked-files=no`, and an uncommitted
|
||
|
|
deletion of a tracked file makes it false for every later move."""
|
||
|
|
taskfiles.archive_task(FILENAME, "backlog")
|
||
|
|
|
||
|
|
self.assertEqual(self.dirty(), "")
|
||
|
|
|
||
|
|
def test_the_archive_commit_is_pushed_like_a_move(self):
|
||
|
|
"""Event-driven push hangs off state.task_committed(), so routing
|
||
|
|
through the same helper publishes the archive without sync having
|
||
|
|
to know it happened."""
|
||
|
|
published: list[str] = []
|
||
|
|
state.COMMIT_HOOKS.append(published.append)
|
||
|
|
self.addCleanup(state.COMMIT_HOOKS.remove, published.append)
|
||
|
|
|
||
|
|
taskfiles.archive_task(FILENAME, "backlog")
|
||
|
|
|
||
|
|
self.assertEqual(published, [FILENAME])
|
||
|
|
|
||
|
|
def test_a_card_git_has_never_seen_is_archived_as_an_addition(self):
|
||
|
|
"""A brand-new backlog file has no path in HEAD, so naming the
|
||
|
|
source in the pathspec would fail the commit outright."""
|
||
|
|
fresh = "45-brand-new.md"
|
||
|
|
(self.repo / "tasks" / "backlog" / fresh).write_text(
|
||
|
|
CARD.replace("44", "45"), encoding="utf-8")
|
||
|
|
|
||
|
|
taskfiles.archive_task(fresh, "backlog")
|
||
|
|
|
||
|
|
self.assertEqual(self.commit_count(), self.baseline + 1)
|
||
|
|
self.assertEqual(self.head_message(), f"board: 45 → archived ({MOVER})")
|
||
|
|
self.assertEqual(self.head_files(), ["tasks/archive/" + fresh])
|
||
|
|
self.assertEqual(self.porcelain(), "")
|
||
|
|
|
||
|
|
def test_the_archive_names_who_did_it_rather_than_the_disk(self):
|
||
|
|
taskfiles.archive_task(FILENAME, "backlog")
|
||
|
|
|
||
|
|
self.assertEqual(state.claim_expected(FILENAME, "archived"), "you",
|
||
|
|
"the mover registers itself, as every other move does")
|
||
|
|
|
||
|
|
# — the way back —
|
||
|
|
|
||
|
|
def test_the_undo_commits_its_own_restore(self):
|
||
|
|
taskfiles.archive_task(FILENAME, "backlog")
|
||
|
|
|
||
|
|
taskfiles.unarchive_task(FILENAME, "backlog")
|
||
|
|
|
||
|
|
self.assertEqual(self.commit_count(), self.baseline + 2,
|
||
|
|
"one commit out, one commit back")
|
||
|
|
self.assertEqual(self.head_message(), f"board: 44 → backlog ({MOVER})")
|
||
|
|
self.assertEqual(self.head_files(),
|
||
|
|
["tasks/archive/" + FILENAME, "tasks/backlog/" + FILENAME])
|
||
|
|
self.assertEqual(self.porcelain(), "")
|
||
|
|
self.assertEqual(self.read("backlog"), CARD,
|
||
|
|
"the card comes back exactly as it went")
|
||
|
|
|
||
|
|
def test_the_undo_restores_the_status_of_the_stage_it_returns_to(self):
|
||
|
|
shutil.move(str(self.path("backlog")), str(self.path("to-do")))
|
||
|
|
self.write("to-do", CARD.replace("Backlog", "To Do"))
|
||
|
|
git(self.repo, "add", "-A")
|
||
|
|
git(self.repo, "commit", "-q", "-m", "hand move to to-do")
|
||
|
|
self.baseline = self.commit_count()
|
||
|
|
taskfiles.archive_task(FILENAME, "to-do")
|
||
|
|
|
||
|
|
taskfiles.unarchive_task(FILENAME, "to-do")
|
||
|
|
|
||
|
|
self.assertIn("**Status:** To Do", self.read("to-do"))
|
||
|
|
self.assertEqual(self.head_message(), f"board: 44 → to-do ({MOVER})")
|
||
|
|
|
||
|
|
def test_the_restored_card_is_attributed_to_the_person_on_the_ticker(self):
|
||
|
|
"""The watcher sees the card reappear in a stage directory; without
|
||
|
|
an expectation registered it would call that appearance `disk`."""
|
||
|
|
taskfiles.archive_task(FILENAME, "backlog")
|
||
|
|
before = {slug: set() for slug in config.STAGE_DIRS}
|
||
|
|
|
||
|
|
taskfiles.unarchive_task(FILENAME, "backlog")
|
||
|
|
state.BOARD_EVENTS.clear()
|
||
|
|
watch.narrate(before, {slug: ({FILENAME} if slug == "backlog" else set())
|
||
|
|
for slug in config.STAGE_DIRS})
|
||
|
|
|
||
|
|
events = [e for e in state.BOARD_EVENTS if e.get("file") == FILENAME]
|
||
|
|
self.assertEqual([e["actor"] for e in events], ["you"])
|
||
|
|
|
||
|
|
# — appended reports —
|
||
|
|
|
||
|
|
def test_an_appended_report_commits_too(self):
|
||
|
|
record = {"task": FILENAME, "name": "Wren", "log": "/dev/null"}
|
||
|
|
|
||
|
|
agents._file_report(record, "Work report", "It works.")
|
||
|
|
|
||
|
|
self.assertIn("## Work report", self.read("backlog"))
|
||
|
|
self.assertIn("It works.", self.read("backlog"))
|
||
|
|
self.assertEqual(self.commit_count(), self.baseline + 1)
|
||
|
|
self.assertEqual(self.head_message(), f"board: 44 Work report filed ({MOVER})")
|
||
|
|
self.assertEqual(self.head_files(), ["tasks/backlog/" + FILENAME])
|
||
|
|
self.assertEqual(self.porcelain(), "",
|
||
|
|
"no run leaves a modified task file behind")
|
||
|
|
|
||
|
|
def test_an_empty_report_writes_nothing_and_commits_nothing(self):
|
||
|
|
agents._file_report({"task": FILENAME, "name": "Wren"}, "Work report", "")
|
||
|
|
|
||
|
|
self.assertEqual(self.read("backlog"), CARD)
|
||
|
|
self.assertEqual(self.commit_count(), self.baseline)
|
||
|
|
|
||
|
|
# — the gate —
|
||
|
|
|
||
|
|
def test_gate_off_archives_exactly_as_before(self):
|
||
|
|
self.patch(COMMIT_MOVES=False)
|
||
|
|
|
||
|
|
taskfiles.archive_task(FILENAME, "backlog")
|
||
|
|
|
||
|
|
self.assertEqual(self.commit_count(), self.baseline)
|
||
|
|
self.assertIn("**Status:** Archived", self.read("archive"))
|
||
|
|
self.assertEqual(self.porcelain(),
|
||
|
|
" D tasks/backlog/44-a-card.md\n?? tasks/archive/\n",
|
||
|
|
"the archive stays for a human to commit, index untouched")
|
||
|
|
|
||
|
|
def test_gate_off_files_a_report_without_committing_it(self):
|
||
|
|
self.patch(COMMIT_MOVES=False)
|
||
|
|
|
||
|
|
agents._file_report({"task": FILENAME, "name": "Wren"}, "Work report", "Done.")
|
||
|
|
|
||
|
|
self.assertIn("Done.", self.read("backlog"))
|
||
|
|
self.assertEqual(self.commit_count(), self.baseline)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|