Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .issueflows/03-solved-issues/issue4_original.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Issue #4: update issueflow for already initialized projects

Source: https://github.com/jepegit/issue-flow/issues/4

## Original issue text

Since `issue-flow` is constantly improving (at least constantly changing), we need to make it easy for users to update their already initialized projects.

1. If running `issueflow init` in a repo/project that has already been initialized, make sure that the content that it is likely the user would like to keep (like issue statuses and descriptions) is not destroyed.
2. add a subcommand `issueflow update` that updates the issueflow commands, etc. (for example, a newer version of issue-flow might need more folders).
10 changes: 10 additions & 0 deletions .issueflows/03-solved-issues/issue4_status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Issue #4 — status

## Work completed

- [x] Running `issue-flow init` on an already initialized project preserves user content (issue statuses, descriptions, and similar); clarified messaging and invariants.
- [x] Add `issue-flow update` subcommand to refresh issueflow commands and structure (e.g. new folders when the package evolves).

## Done

- [x] Done
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,33 @@ That's it. Open the project in Cursor and use `/issue-init`, `/issue-start`, `/i

```
issue-flow init [PROJECT_DIR] [--force]
issue-flow update [PROJECT_DIR]
```

### `issue-flow init`

| Argument / Option | Description |
|---|---|
| `PROJECT_DIR` | Project root directory. Defaults to `.` (current directory). |
| `--force`, `-f` | Overwrite generated Cursor commands, rules, and workflow doc instead of skipping them. |

Running `init` again without `--force` is safe: generated scaffold files that already exist are skipped, and **issue markdown under `.issueflows/` is never touched** by `init` or `update`. When the CLI detects an existing scaffold, it reminds you about `update` and `--force`.

### `issue-flow update`

| Argument / Option | Description |
|---|---|
| `PROJECT_DIR` | Project root directory. Defaults to `.` (current directory). |
| `--force`, `-f` | Overwrite existing files instead of skipping them. |

Running `init` a second time is safe — existing files are skipped unless `--force` is passed.
Use `update` after upgrading the **issue-flow** package to refresh the packaged slash commands, Cursor rule, and `docs/cursor-issue-workflow.md` from the version you have installed. This **overwrites** those generated files (unlike a plain second `init`). It still does not modify arbitrary files under `.issueflows/` (for example your `issue*_original.md` / `issue*_status.md` files), and it creates any **new** `.issueflows/` subdirectories required by the current package.

### When to use which

| Goal | Command |
|---|---|
| First-time setup, or add missing files only | `issue-flow init` |
| Pull newer templates after `uv tool upgrade issue-flow` (or similar) | `issue-flow update` |
| Replace generated scaffolds without upgrading logic | `issue-flow init --force` |

## Configuration

Expand Down
3 changes: 2 additions & 1 deletion docs/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Here are the commands you'll use most often:
| Add a dependency | `uv add <package>` |
| Add a dev dependency | `uv add --dev <package>` |
| Run the CLI locally | `uv run issue-flow --help` |
| Refresh scaffold in a test project (same as installed package templates) | `uv run issue-flow update <DIR>` |

Always use `uv run` instead of calling `python` directly. This makes sure you're using the right virtual environment and dependencies.

Expand All @@ -56,7 +57,7 @@ issue-flow/
__init__.py # Version string
cli.py # Command-line interface (typer)
config.py # Settings loaded from .env / environment
init.py # The "init" command logic
init.py # `init` and `update` command logic
templating.py # Jinja2 template loading
templates/ # Templates rendered by "init"
commands/ # Cursor slash command templates
Expand Down
16 changes: 16 additions & 0 deletions src/issue_flow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ def init(
run_init(project_root=project_dir, force=force)


@app.command()
def update(
project_dir: Path = typer.Argument(
default=Path("."),
help="Project root directory (defaults to current directory).",
exists=True,
file_okay=False,
resolve_path=True,
),
) -> None:
"""Refresh packaged Cursor commands, rules, and workflow doc from this package."""
from issue_flow.init import run_update

run_update(project_root=project_dir)


def main() -> None:
"""Entry point for the `issue-flow` console script."""
app()
127 changes: 104 additions & 23 deletions src/issue_flow/init.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Implementation of the `issue-flow init` command."""
"""Implementation of the `issue-flow init` and `issue-flow update` commands."""

from __future__ import annotations

Expand All @@ -16,24 +16,20 @@
console = Console()


def run_init(project_root: Path, force: bool = False) -> None:
"""Scaffold .issueflows/ directories and .cursor/ config files.
def _write_manifest_files(
project_root: Path,
context: dict[str, str],
*,
force: bool,
) -> tuple[list[Path], list[Path]]:
"""Render templates from TEMPLATE_MANIFEST and write under project_root.

Args:
project_root: Absolute path to the user's project directory.
force: If True, overwrite existing files without asking.
"""
settings = Settings()
context = settings.template_context(project_root)
When ``force`` is False, existing files are skipped (not overwritten).
Issue markdown under ``.issueflows/`` is never part of the manifest.

console.print(
f"\n[bold]Initializing issue-flow in [cyan]{project_root}[/cyan][/bold]\n"
)

# ── 1. Create .issueflows/ subdirectories ────────────────────────
_create_issueflow_dirs(project_root, settings)

# ── 2. Render and write template files ───────────────────────────
Returns:
(written_relative_paths, skipped_relative_paths)
"""
written_files: list[Path] = []
skipped_files: list[Path] = []

Expand All @@ -54,12 +50,58 @@ def run_init(project_root: Path, force: bool = False) -> None:
console.print(f" [green]write[/green] {relative_path}")
written_files.append(relative_path)

# ── 3. Summary ───────────────────────────────────────────────────
console.print()
if written_files:
return written_files, skipped_files


def _already_initialized(
project_root: Path, settings: Settings, context: dict[str, str]
) -> bool:
"""True if the tree looks like issue-flow was set up here before."""
base = project_root / settings.issueflows_dir
if not base.is_dir():
return False
return any(
(project_root / resolve_output_path(path_template, context)).is_file()
for _, path_template in TEMPLATE_MANIFEST
)


def run_init(project_root: Path, force: bool = False) -> None:
"""Scaffold .issueflows/ directories and .cursor/ config files.

Re-running without ``force`` skips existing manifest outputs so local
edits and issue markdown under ``.issueflows/`` are preserved. Manifest
paths never include issue status or description files.

Args:
project_root: Absolute path to the user's project directory.
force: If True, overwrite existing manifest files without asking.
"""
settings = Settings()
context = settings.template_context(project_root)

console.print(
f"\n[bold]Initializing issue-flow in [cyan]{project_root}[/cyan][/bold]\n"
)

if not force and _already_initialized(project_root, settings, context):
console.print(
f"[bold green]Created {len(written_files)} file(s).[/bold green]"
"[dim]This project already has issue-flow scaffold files. "
"Existing files are skipped so your issue notes stay intact. "
"Run [bold]issue-flow update[/bold] to refresh commands, rules, and docs "
"from your installed package version. Use [bold]issue-flow init --force[/bold] "
"to overwrite scaffold files in place.[/dim]\n"
)

_create_issueflow_dirs(project_root, settings)

written_files, skipped_files = _write_manifest_files(
project_root, context, force=force
)

console.print()
if written_files:
console.print(f"[bold green]Created {len(written_files)} file(s).[/bold green]")
if skipped_files:
console.print(
f"[bold yellow]Skipped {len(skipped_files)} existing file(s).[/bold yellow]"
Expand All @@ -74,17 +116,56 @@ def run_init(project_root: Path, force: bool = False) -> None:
)


def run_update(project_root: Path) -> None:
"""Refresh packaged scaffold files (Cursor commands, rule, workflow doc).

Overwrites every path in ``TEMPLATE_MANIFEST`` with the templates from the
installed package. Does not read or delete other files under ``.issueflows/``
(issue markdown is never written by the manifest).

Ensures ``.issueflows/`` subdirectories from settings exist (e.g. new
folders in a newer package version).
"""
settings = Settings()
context = settings.template_context(project_root)

console.print(
f"\n[bold]Updating issue-flow scaffold in [cyan]{project_root}[/cyan][/bold]\n"
)

_create_issueflow_dirs(project_root, settings)

written_files, _skipped = _write_manifest_files(project_root, context, force=True)

console.print()
if written_files:
console.print(
f"[bold green]Refreshed {len(written_files)} file(s).[/bold green]"
)
else:
console.print("[bold]Nothing to write.[/bold]")

console.print(
"\n[dim]Manifest outputs were overwritten from the installed package. "
"Issue files under [bold].issueflows/[/bold] were not modified by this command.[/dim]\n"
)


def _create_issueflow_dirs(project_root: Path, settings: Settings) -> None:
"""Create the .issueflows/ directory tree."""
base = project_root / settings.issueflows_dir

for subdir_name in settings.issueflows_subdirs:
dir_path = base / subdir_name
if dir_path.exists():
console.print(f" [dim]exists[/dim] {settings.issueflows_dir}/{subdir_name}/")
console.print(
f" [dim]exists[/dim] {settings.issueflows_dir}/{subdir_name}/"
)
else:
dir_path.mkdir(parents=True, exist_ok=True)
console.print(f" [green]mkdir[/green] {settings.issueflows_dir}/{subdir_name}/")
console.print(
f" [green]mkdir[/green] {settings.issueflows_dir}/{subdir_name}/"
)

gitkeep = dir_path / ".gitkeep"
if not gitkeep.exists():
Expand Down
6 changes: 5 additions & 1 deletion src/issue_flow/templating.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ def get_source(
) -> tuple[str, str, callable]:
# template is e.g. "commands/issue-init.md.j2"
parts = template.replace("\\", "/").split("/")
package = _TEMPLATES_PACKAGE + "." + ".".join(parts[:-1]) if len(parts) > 1 else _TEMPLATES_PACKAGE
package = (
_TEMPLATES_PACKAGE + "." + ".".join(parts[:-1])
if len(parts) > 1
else _TEMPLATES_PACKAGE
)
filename = parts[-1]

try:
Expand Down
11 changes: 9 additions & 2 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ def test_init_creates_gitkeep_files(tmp_path: Path) -> None:
run_init(tmp_path)

issueflows = tmp_path / ".issueflows"
for subdir in ["00-tools", "01-current-issues", "02-partly-solved-issues", "03-solved-issues"]:
for subdir in [
"00-tools",
"01-current-issues",
"02-partly-solved-issues",
"03-solved-issues",
]:
gitkeep = issueflows / subdir / ".gitkeep"
assert gitkeep.is_file(), f"{subdir}/.gitkeep should exist"

Expand Down Expand Up @@ -97,7 +102,9 @@ def test_init_templates_reference_issueflows_dir(tmp_path: Path) -> None:
def test_init_issue_init_documents_branch_inference(tmp_path: Path) -> None:
"""issue-init.md should describe resolving an issue from the current branch when no args."""
run_init(tmp_path)
content = (tmp_path / ".cursor" / "commands" / "issue-init.md").read_text(encoding="utf-8")
content = (tmp_path / ".cursor" / "commands" / "issue-init.md").read_text(
encoding="utf-8"
)
assert "git branch --show-current" in content
assert "You have not provided an issue reference" in content
assert "issue-style branch" in content
Expand Down
50 changes: 50 additions & 0 deletions tests/test_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Tests for issue_flow.init.run_update."""

from __future__ import annotations

import shutil
from pathlib import Path

from issue_flow.init import run_init, run_update


def test_update_overwrites_scaffold(tmp_path: Path) -> None:
"""update should overwrite manifest files even when customized."""
run_init(tmp_path)

rule_file = tmp_path / ".cursor" / "rules" / "issueflow-rules.mdc"
rule_file.write_text("custom content", encoding="utf-8")

run_update(tmp_path)

content = rule_file.read_text(encoding="utf-8")
assert content != "custom content"
assert "alwaysApply: true" in content


def test_update_preserves_issue_markdown(tmp_path: Path) -> None:
"""update must not modify issue markdown under .issueflows/."""
run_init(tmp_path)

issues_dir = tmp_path / ".issueflows" / "01-current-issues"
issue_file = issues_dir / "issue99_original.md"
distinctive = "USER_ISSUE_BODY_SHOULD_STAY_PUT\n"
issue_file.write_text(distinctive, encoding="utf-8")

run_update(tmp_path)

assert issue_file.read_text(encoding="utf-8") == distinctive


def test_update_recreates_removed_subdir(tmp_path: Path) -> None:
"""If an issueflows subdir was removed, update should recreate it."""
run_init(tmp_path)

removed = tmp_path / ".issueflows" / "00-tools"
shutil.rmtree(removed)
assert not removed.exists()

run_update(tmp_path)

assert removed.is_dir()
assert (removed / ".gitkeep").is_file()
Loading