monoco-toolkit 0.3.11__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- monoco/core/automation/__init__.py +40 -0
- monoco/core/automation/field_watcher.py +296 -0
- monoco/core/automation/handlers.py +805 -0
- monoco/core/config.py +29 -11
- monoco/core/daemon/__init__.py +5 -0
- monoco/core/daemon/pid.py +290 -0
- monoco/core/git.py +15 -0
- monoco/core/hooks/context.py +74 -13
- monoco/core/injection.py +86 -8
- monoco/core/integrations.py +0 -24
- monoco/core/router/__init__.py +17 -0
- monoco/core/router/action.py +202 -0
- monoco/core/scheduler/__init__.py +63 -0
- monoco/core/scheduler/base.py +152 -0
- monoco/core/scheduler/engines.py +175 -0
- monoco/core/scheduler/events.py +197 -0
- monoco/core/scheduler/local.py +377 -0
- monoco/core/setup.py +9 -0
- monoco/core/sync.py +199 -4
- monoco/core/watcher/__init__.py +63 -0
- monoco/core/watcher/base.py +382 -0
- monoco/core/watcher/dropzone.py +152 -0
- monoco/core/watcher/im.py +460 -0
- monoco/core/watcher/issue.py +303 -0
- monoco/core/watcher/memo.py +192 -0
- monoco/core/watcher/task.py +238 -0
- monoco/daemon/app.py +3 -60
- monoco/daemon/commands.py +459 -25
- monoco/daemon/events.py +34 -0
- monoco/daemon/scheduler.py +157 -201
- monoco/daemon/services.py +42 -243
- monoco/features/agent/__init__.py +25 -7
- monoco/features/agent/cli.py +91 -57
- monoco/features/agent/engines.py +31 -170
- monoco/features/agent/resources/en/AGENTS.md +14 -14
- monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/agent/worker.py +1 -1
- monoco/features/hooks/__init__.py +61 -6
- monoco/features/hooks/commands.py +281 -271
- monoco/features/hooks/dispatchers/__init__.py +23 -0
- monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
- monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
- monoco/features/hooks/manager.py +357 -0
- monoco/features/hooks/models.py +262 -0
- monoco/features/hooks/parser.py +322 -0
- monoco/features/hooks/universal_interceptor.py +503 -0
- monoco/features/im/__init__.py +67 -0
- monoco/features/im/core.py +782 -0
- monoco/features/im/models.py +311 -0
- monoco/features/issue/commands.py +133 -60
- monoco/features/issue/core.py +385 -40
- monoco/features/issue/domain_commands.py +0 -19
- monoco/features/issue/resources/en/AGENTS.md +17 -122
- monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
- monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
- monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
- monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
- monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
- monoco/features/issue/resources/zh/AGENTS.md +18 -123
- monoco/features/memo/cli.py +15 -64
- monoco/features/memo/core.py +6 -34
- monoco/features/memo/models.py +24 -15
- monoco/features/memo/resources/en/AGENTS.md +31 -0
- monoco/features/memo/resources/zh/AGENTS.md +28 -5
- monoco/features/spike/commands.py +5 -3
- monoco/main.py +5 -3
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
- monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
- monoco/core/execution.py +0 -67
- monoco/features/agent/apoptosis.py +0 -44
- monoco/features/agent/manager.py +0 -127
- monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
- monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
- monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
- monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
- monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
- monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
- monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
- monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
- monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
- monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
- monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
- monoco/features/agent/session.py +0 -169
- monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
- monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/hooks/adapter.py +0 -67
- monoco/features/hooks/core.py +0 -441
- monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
- monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco_toolkit-0.3.11.dist-info/RECORD +0 -181
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/licenses/LICENSE +0 -0
monoco/features/issue/core.py
CHANGED
|
@@ -46,6 +46,56 @@ def get_issue_dir(issue_type: str, issues_root: Path) -> Path:
|
|
|
46
46
|
return issues_root / folder
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def _extract_issue_id_from_branch(branch: str) -> Optional[str]:
|
|
50
|
+
"""
|
|
51
|
+
Extract issue ID from a branch name.
|
|
52
|
+
|
|
53
|
+
Supported patterns:
|
|
54
|
+
- <ID>-slug (e.g., FEAT-0123-login-page)
|
|
55
|
+
- <ID> (e.g., FEAT-0123)
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
branch: Branch name to parse
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The extracted issue ID (uppercase) or None if no match
|
|
62
|
+
"""
|
|
63
|
+
# Pattern: <prefix>-<number> at start of branch name
|
|
64
|
+
# Examples:
|
|
65
|
+
# FEAT-0123-login-page -> FEAT-0123
|
|
66
|
+
# FEAT-0123 -> FEAT-0123
|
|
67
|
+
# FIX-0001-critical -> FIX-0001
|
|
68
|
+
pattern = r'^([a-z]+-\d+)'
|
|
69
|
+
match = re.match(pattern, branch, re.IGNORECASE)
|
|
70
|
+
if match:
|
|
71
|
+
return match.group(1).upper()
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_isolation_ref(ref: str) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Parse isolation ref and strip any 'branch:' or 'worktree:' prefix.
|
|
78
|
+
|
|
79
|
+
The isolation.ref field may contain prefixes like 'branch:feat/xxx' or 'worktree:xxx'
|
|
80
|
+
for display/logging purposes, but git commands need the raw branch name.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
ref: The isolation ref string, may contain prefix
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The clean branch name without prefix
|
|
87
|
+
"""
|
|
88
|
+
if not ref:
|
|
89
|
+
return ref
|
|
90
|
+
|
|
91
|
+
# Strip known prefixes
|
|
92
|
+
for prefix in ("branch:", "worktree:"):
|
|
93
|
+
if ref.startswith(prefix):
|
|
94
|
+
return ref[len(prefix):]
|
|
95
|
+
|
|
96
|
+
return ref
|
|
97
|
+
|
|
98
|
+
|
|
49
99
|
def _get_slug(title: str) -> str:
|
|
50
100
|
slug = title.lower()
|
|
51
101
|
# Replace non-word characters (including punctuation, spaces) with hyphens
|
|
@@ -59,6 +109,55 @@ def _get_slug(title: str) -> str:
|
|
|
59
109
|
return slug
|
|
60
110
|
|
|
61
111
|
|
|
112
|
+
def _unquote_git_path(path: str) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Decode Git C-quoting format paths to native strings.
|
|
115
|
+
|
|
116
|
+
Git uses C-quoting for paths containing non-ASCII characters or special characters:
|
|
117
|
+
- Wraps the path in double quotes: "path"
|
|
118
|
+
- Escapes bytes as octal: \351\207\215 represents UTF-8 bytes
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path: Path string, potentially in Git C-quoting format
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Decoded native path string
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
>>> _unquote_git_path('"path-\\351\\207\\215.md"')
|
|
128
|
+
'path-重.md'
|
|
129
|
+
>>> _unquote_git_path('"path\\ with\\ space.md"')
|
|
130
|
+
'path with space.md'
|
|
131
|
+
>>> _unquote_git_path('normal/path.md')
|
|
132
|
+
'normal/path.md'
|
|
133
|
+
"""
|
|
134
|
+
path = path.strip()
|
|
135
|
+
|
|
136
|
+
# Check if the path is wrapped in double quotes (C-quoting format)
|
|
137
|
+
if not (path.startswith('"') and path.endswith('"')):
|
|
138
|
+
return path
|
|
139
|
+
|
|
140
|
+
# Remove outer quotes
|
|
141
|
+
path = path[1:-1]
|
|
142
|
+
|
|
143
|
+
# Collect all bytes (octal escapes -> bytes, ASCII chars -> their byte values)
|
|
144
|
+
bytes_list = []
|
|
145
|
+
i = 0
|
|
146
|
+
while i < len(path):
|
|
147
|
+
if path[i] == '\\' and i + 3 < len(path) and path[i+1:i+4].isdigit():
|
|
148
|
+
# This is an octal escape sequence (e.g., \351)
|
|
149
|
+
byte_val = int(path[i+1:i+4], 8)
|
|
150
|
+
bytes_list.append(byte_val)
|
|
151
|
+
i += 4
|
|
152
|
+
else:
|
|
153
|
+
# Regular ASCII character
|
|
154
|
+
bytes_list.append(ord(path[i]))
|
|
155
|
+
i += 1
|
|
156
|
+
|
|
157
|
+
# Decode the entire byte sequence as UTF-8
|
|
158
|
+
return bytes(bytes_list).decode('utf-8')
|
|
159
|
+
|
|
160
|
+
|
|
62
161
|
def parse_issue(file_path: Path, raise_error: bool = False) -> Optional[IssueMetadata]:
|
|
63
162
|
if not file_path.suffix == ".md":
|
|
64
163
|
return None
|
|
@@ -466,6 +565,192 @@ def find_issue_path(issues_root: Path, issue_id: str, include_archived: bool = T
|
|
|
466
565
|
return None
|
|
467
566
|
|
|
468
567
|
|
|
568
|
+
def find_issue_path_across_branches(
|
|
569
|
+
issues_root: Path,
|
|
570
|
+
issue_id: str,
|
|
571
|
+
project_root: Optional[Path] = None,
|
|
572
|
+
include_archived: bool = True,
|
|
573
|
+
allow_multi_branch: bool = False
|
|
574
|
+
) -> Tuple[Optional[Path], Optional[str], Optional[List[str]]]:
|
|
575
|
+
"""
|
|
576
|
+
Find issue path across all local git branches.
|
|
577
|
+
|
|
578
|
+
Implements the "Golden Path" logic:
|
|
579
|
+
- If issue found in exactly one branch -> return it silently
|
|
580
|
+
- If issue found in multiple branches -> handle based on allow_multi_branch flag
|
|
581
|
+
- If issue not found in any branch -> return None
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
issues_root: Root directory of issues
|
|
585
|
+
issue_id: Issue ID to find
|
|
586
|
+
project_root: Project root (defaults to issues_root.parent)
|
|
587
|
+
include_archived: Whether to search in archived directory
|
|
588
|
+
allow_multi_branch: If True, return all conflicting branches instead of raising error
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Tuple of (file_path, branch_name, conflicting_branches) or (None, None, None) if not found
|
|
592
|
+
conflicting_branches is None if no conflict, or a list of branch names if conflict detected
|
|
593
|
+
|
|
594
|
+
Raises:
|
|
595
|
+
RuntimeError: If issue found in multiple branches and allow_multi_branch is False (conflict)
|
|
596
|
+
"""
|
|
597
|
+
# First, try to find in current working tree
|
|
598
|
+
local_path = find_issue_path(issues_root, issue_id, include_archived)
|
|
599
|
+
|
|
600
|
+
# Determine project root
|
|
601
|
+
if project_root is None:
|
|
602
|
+
project_root = issues_root.parent
|
|
603
|
+
|
|
604
|
+
# If not a git repo, just return local result
|
|
605
|
+
if not git.is_git_repo(project_root):
|
|
606
|
+
return (local_path, None, None) if local_path else (None, None, None)
|
|
607
|
+
|
|
608
|
+
# Get current branch
|
|
609
|
+
current_branch = git.get_current_branch(project_root)
|
|
610
|
+
|
|
611
|
+
# Issue files existing in multiple branches is NORMAL - not a conflict
|
|
612
|
+
# Just return the local path without any conflict checking
|
|
613
|
+
if local_path:
|
|
614
|
+
return local_path, current_branch, None
|
|
615
|
+
|
|
616
|
+
# Not found locally, search in all branches
|
|
617
|
+
return _search_issue_in_branches(issues_root, issue_id, project_root, include_archived)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _find_branches_with_file(project_root: Path, rel_path: str, exclude_branch: str) -> List[str]:
|
|
621
|
+
"""
|
|
622
|
+
Find all branches (except excluded) that contain the given file path.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
project_root: Project root path
|
|
626
|
+
rel_path: Relative path from project root
|
|
627
|
+
exclude_branch: Branch to exclude from search
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
List of branch names containing the file
|
|
631
|
+
"""
|
|
632
|
+
branches_with_file = []
|
|
633
|
+
|
|
634
|
+
# Get all local branches
|
|
635
|
+
code, stdout, _ = git._run_git(["branch", "--format=%(refname:short)"], project_root)
|
|
636
|
+
if code != 0:
|
|
637
|
+
return []
|
|
638
|
+
|
|
639
|
+
all_branches = [b.strip() for b in stdout.splitlines() if b.strip()]
|
|
640
|
+
|
|
641
|
+
for branch in all_branches:
|
|
642
|
+
if branch == exclude_branch:
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
# Check if file exists in this branch
|
|
646
|
+
code, _, _ = git._run_git(["show", f"{branch}:{rel_path}"], project_root)
|
|
647
|
+
if code == 0:
|
|
648
|
+
branches_with_file.append(branch)
|
|
649
|
+
|
|
650
|
+
return branches_with_file
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _search_issue_in_branches(
|
|
654
|
+
issues_root: Path,
|
|
655
|
+
issue_id: str,
|
|
656
|
+
project_root: Path,
|
|
657
|
+
include_archived: bool = True
|
|
658
|
+
) -> Tuple[Optional[Path], Optional[str], Optional[List[str]]]:
|
|
659
|
+
"""
|
|
660
|
+
Search for an issue file across all branches.
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
Tuple of (file_path, branch_name, conflicting_branches) or (None, None, None)
|
|
664
|
+
conflicting_branches is None if no conflict, or a list of branch names if conflict detected
|
|
665
|
+
|
|
666
|
+
Raises:
|
|
667
|
+
RuntimeError: If issue found in multiple branches and allow_multi_branch is False
|
|
668
|
+
"""
|
|
669
|
+
parsed = IssueID(issue_id)
|
|
670
|
+
|
|
671
|
+
if not parsed.is_local:
|
|
672
|
+
# For workspace issues, just use standard find
|
|
673
|
+
path = find_issue_path(issues_root, issue_id, include_archived)
|
|
674
|
+
return (path, None, None) if path else (None, None, None)
|
|
675
|
+
|
|
676
|
+
# Get issue type from prefix
|
|
677
|
+
try:
|
|
678
|
+
prefix = parsed.local_id.split("-")[0].upper()
|
|
679
|
+
except IndexError:
|
|
680
|
+
return None, None, None
|
|
681
|
+
|
|
682
|
+
reverse_prefix_map = get_reverse_prefix_map(issues_root)
|
|
683
|
+
issue_type = reverse_prefix_map.get(prefix)
|
|
684
|
+
if not issue_type:
|
|
685
|
+
return None, None, None
|
|
686
|
+
|
|
687
|
+
# Build possible paths to search
|
|
688
|
+
base_dir = get_issue_dir(issue_type, issues_root)
|
|
689
|
+
rel_base = base_dir.relative_to(project_root)
|
|
690
|
+
|
|
691
|
+
status_dirs = ["open", "backlog", "closed"]
|
|
692
|
+
if include_archived:
|
|
693
|
+
status_dirs.append("archived")
|
|
694
|
+
|
|
695
|
+
# Search pattern: Issues/{Type}/{status}/{issue_id}-*.md
|
|
696
|
+
found_in_branches: List[Tuple[str, str]] = [] # (branch, file_path)
|
|
697
|
+
|
|
698
|
+
# Get all local branches
|
|
699
|
+
code, stdout, _ = git._run_git(["branch", "--format=%(refname:short)"], project_root)
|
|
700
|
+
if code != 0:
|
|
701
|
+
return None, None, None
|
|
702
|
+
|
|
703
|
+
all_branches = [b.strip() for b in stdout.splitlines() if b.strip()]
|
|
704
|
+
|
|
705
|
+
for branch in all_branches:
|
|
706
|
+
# List files in each status directory for this branch
|
|
707
|
+
for status_dir in status_dirs:
|
|
708
|
+
dir_path = f"{rel_base}/{status_dir}"
|
|
709
|
+
|
|
710
|
+
# List all files in this directory on the branch
|
|
711
|
+
code, stdout, _ = git._run_git(
|
|
712
|
+
["ls-tree", "-r", "--name-only", branch, dir_path],
|
|
713
|
+
project_root
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
if code != 0 or not stdout.strip():
|
|
717
|
+
continue
|
|
718
|
+
|
|
719
|
+
# Find matching issue file (Handling quoted paths from git ls-tree)
|
|
720
|
+
pattern = f"{parsed.local_id}-"
|
|
721
|
+
for line in stdout.splitlines():
|
|
722
|
+
line = line.strip()
|
|
723
|
+
# Git quotes non-ASCII paths: "Issues/Chores/...md"
|
|
724
|
+
if line.startswith('"') and line.endswith('"'):
|
|
725
|
+
line = line[1:-1]
|
|
726
|
+
|
|
727
|
+
if pattern in line and line.endswith(".md"):
|
|
728
|
+
found_in_branches.append((branch, line))
|
|
729
|
+
|
|
730
|
+
if not found_in_branches:
|
|
731
|
+
return None, None, None
|
|
732
|
+
|
|
733
|
+
if len(found_in_branches) > 1:
|
|
734
|
+
# Found in multiple branches - return all branches for caller to handle
|
|
735
|
+
branches = [b for b, _ in found_in_branches]
|
|
736
|
+
branch, file_path = found_in_branches[0]
|
|
737
|
+
full_path = project_root / file_path
|
|
738
|
+
return full_path, branch, branches
|
|
739
|
+
|
|
740
|
+
# Golden path: exactly one match
|
|
741
|
+
branch, file_path = found_in_branches[0]
|
|
742
|
+
|
|
743
|
+
# Checkout the file to working tree
|
|
744
|
+
try:
|
|
745
|
+
git.git_checkout_files(project_root, branch, [file_path])
|
|
746
|
+
full_path = project_root / file_path
|
|
747
|
+
return full_path, branch, None
|
|
748
|
+
except Exception:
|
|
749
|
+
# If checkout fails, return the path anyway - caller can handle
|
|
750
|
+
full_path = project_root / file_path
|
|
751
|
+
return full_path, branch, None
|
|
752
|
+
|
|
753
|
+
|
|
469
754
|
def update_issue(
|
|
470
755
|
issues_root: Path,
|
|
471
756
|
issue_id: str,
|
|
@@ -823,7 +1108,7 @@ def start_issue_isolation(
|
|
|
823
1108
|
)
|
|
824
1109
|
|
|
825
1110
|
slug = _get_slug(issue.title)
|
|
826
|
-
branch_name = f"
|
|
1111
|
+
branch_name = f"{issue_id}-{slug}"
|
|
827
1112
|
|
|
828
1113
|
isolation_meta = None
|
|
829
1114
|
|
|
@@ -899,7 +1184,7 @@ def prune_issue_resources(
|
|
|
899
1184
|
return []
|
|
900
1185
|
|
|
901
1186
|
if issue.isolation.type == IsolationType.BRANCH:
|
|
902
|
-
branch = issue.isolation.ref
|
|
1187
|
+
branch = _parse_isolation_ref(issue.isolation.ref)
|
|
903
1188
|
current = git.get_current_branch(project_root)
|
|
904
1189
|
if current == branch:
|
|
905
1190
|
raise RuntimeError(
|
|
@@ -925,7 +1210,7 @@ def prune_issue_resources(
|
|
|
925
1210
|
# Also delete the branch associated?
|
|
926
1211
|
# Worktree create makes a branch. When removing worktree, branch remains.
|
|
927
1212
|
# Usually we want to remove the branch too if it was created for this issue.
|
|
928
|
-
branch = issue.isolation.ref
|
|
1213
|
+
branch = _parse_isolation_ref(issue.isolation.ref)
|
|
929
1214
|
if branch and git.branch_exists(project_root, branch):
|
|
930
1215
|
# We can't delete branch if it is checked out in the worktree we just removed?
|
|
931
1216
|
# git worktree remove unlocks the branch.
|
|
@@ -961,9 +1246,41 @@ def delete_issue_file(issues_root: Path, issue_id: str):
|
|
|
961
1246
|
path.unlink()
|
|
962
1247
|
|
|
963
1248
|
|
|
1249
|
+
def _has_uncommitted_changes(project_root: Path) -> Tuple[bool, List[str]]:
|
|
1250
|
+
"""
|
|
1251
|
+
Check if there are uncommitted changes in the working directory.
|
|
1252
|
+
|
|
1253
|
+
Returns:
|
|
1254
|
+
Tuple of (has_changes, list_of_changed_files)
|
|
1255
|
+
"""
|
|
1256
|
+
# Check for staged changes
|
|
1257
|
+
cmd_staged = ["diff", "--cached", "--name-only"]
|
|
1258
|
+
code, stdout, _ = git._run_git(cmd_staged, project_root)
|
|
1259
|
+
staged_files = [f.strip() for f in stdout.splitlines() if f.strip()] if code == 0 else []
|
|
1260
|
+
|
|
1261
|
+
# Check for unstaged changes
|
|
1262
|
+
cmd_unstaged = ["diff", "--name-only"]
|
|
1263
|
+
code, stdout, _ = git._run_git(cmd_unstaged, project_root)
|
|
1264
|
+
unstaged_files = [f.strip() for f in stdout.splitlines() if f.strip()] if code == 0 else []
|
|
1265
|
+
|
|
1266
|
+
# Check for untracked files
|
|
1267
|
+
cmd_untracked = ["ls-files", "--others", "--exclude-standard"]
|
|
1268
|
+
code, stdout, _ = git._run_git(cmd_untracked, project_root)
|
|
1269
|
+
untracked_files = [f.strip() for f in stdout.splitlines() if f.strip()] if code == 0 else []
|
|
1270
|
+
|
|
1271
|
+
all_changes = list(set(staged_files + unstaged_files + untracked_files))
|
|
1272
|
+
all_changes.sort()
|
|
1273
|
+
|
|
1274
|
+
return len(all_changes) > 0, all_changes
|
|
1275
|
+
|
|
1276
|
+
|
|
964
1277
|
def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> List[str]:
|
|
965
1278
|
"""
|
|
966
1279
|
Sync 'files' field in issue metadata with actual changed files in git.
|
|
1280
|
+
|
|
1281
|
+
Pre-condition: All changes must be committed. If there are uncommitted changes,
|
|
1282
|
+
this function will raise an error with instructions on how to proceed.
|
|
1283
|
+
|
|
967
1284
|
Strategies:
|
|
968
1285
|
1. Isolation Ref: If issue has isolation (branch/worktree), use that ref.
|
|
969
1286
|
2. Convention: If no isolation, look for branch `*/<id>-*`.
|
|
@@ -979,23 +1296,37 @@ def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> Li
|
|
|
979
1296
|
if not issue:
|
|
980
1297
|
raise ValueError(f"Could not parse issue {issue_id}")
|
|
981
1298
|
|
|
1299
|
+
# CHORE-0036: Check for uncommitted changes
|
|
1300
|
+
has_uncommitted, uncommitted_files = _has_uncommitted_changes(project_root)
|
|
1301
|
+
if has_uncommitted:
|
|
1302
|
+
files_list = "\n - ".join([""] + uncommitted_files)
|
|
1303
|
+
raise RuntimeError(
|
|
1304
|
+
f"Uncommitted changes detected in working directory:{files_list}\n\n"
|
|
1305
|
+
f"You must explicitly handle these files before syncing:\n"
|
|
1306
|
+
f" 1. git add <files> && git commit -m '...' (to commit)\n"
|
|
1307
|
+
f" 2. git checkout -- <files> (to discard)\n"
|
|
1308
|
+
f" 3. echo '<pattern>' >> .gitignore (to ignore)\n"
|
|
1309
|
+
f" 4. git rm <files> (to remove)\n\n"
|
|
1310
|
+
f"After handling, run: monoco issue sync-files {issue_id}"
|
|
1311
|
+
)
|
|
1312
|
+
|
|
982
1313
|
# Determine Target Branch
|
|
983
1314
|
target_ref = None
|
|
984
1315
|
|
|
985
1316
|
if issue.isolation and issue.isolation.ref:
|
|
986
|
-
target_ref = issue.isolation.ref
|
|
1317
|
+
target_ref = _parse_isolation_ref(issue.isolation.ref)
|
|
987
1318
|
if target_ref == "current":
|
|
988
1319
|
target_ref = git.get_current_branch(project_root)
|
|
989
1320
|
else:
|
|
990
|
-
# Heuristic Search
|
|
991
|
-
# 1. Is current branch
|
|
1321
|
+
# Heuristic Search - STRICT ID MATCHING ONLY
|
|
1322
|
+
# 1. Is current branch's extracted ID exactly matching the issue ID?
|
|
992
1323
|
current = git.get_current_branch(project_root)
|
|
993
|
-
|
|
1324
|
+
branch_issue_id = _extract_issue_id_from_branch(current)
|
|
1325
|
+
if branch_issue_id and branch_issue_id.upper() == issue_id.upper():
|
|
994
1326
|
target_ref = current
|
|
995
1327
|
else:
|
|
996
|
-
# 2. Search for branch
|
|
997
|
-
#
|
|
998
|
-
# We skip this for now to avoid complexity, relying on isolation or current context.
|
|
1328
|
+
# 2. Search for branch - not implemented yet
|
|
1329
|
+
# To avoid ambiguity, we only match current branch if it exactly matches
|
|
999
1330
|
pass
|
|
1000
1331
|
|
|
1001
1332
|
if not target_ref:
|
|
@@ -1025,6 +1356,14 @@ def sync_issue_files(issues_root: Path, issue_id: str, project_root: Path) -> Li
|
|
|
1025
1356
|
|
|
1026
1357
|
changed_files = [f.strip() for f in stdout.splitlines() if f.strip()]
|
|
1027
1358
|
|
|
1359
|
+
# Decode Git C-quoting format paths to native paths
|
|
1360
|
+
changed_files = [_unquote_git_path(f) for f in changed_files]
|
|
1361
|
+
|
|
1362
|
+
# FEAT-0172: Exclude issue file itself from files list
|
|
1363
|
+
# Issue file is handled separately by update_issue (directory move + status update)
|
|
1364
|
+
relative_issue_path = str(path.relative_to(project_root))
|
|
1365
|
+
changed_files = [f for f in changed_files if f != relative_issue_path]
|
|
1366
|
+
|
|
1028
1367
|
# Sort for consistency
|
|
1029
1368
|
changed_files.sort()
|
|
1030
1369
|
|
|
@@ -1061,30 +1400,23 @@ def merge_issue_changes(
|
|
|
1061
1400
|
# If not there, we try heuristic.
|
|
1062
1401
|
source_ref = None
|
|
1063
1402
|
if issue.isolation and issue.isolation.ref:
|
|
1064
|
-
source_ref = issue.isolation.ref
|
|
1403
|
+
source_ref = _parse_isolation_ref(issue.isolation.ref)
|
|
1065
1404
|
else:
|
|
1066
|
-
# Heuristic: Search for branch by convention
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1405
|
+
# Heuristic: Search for branch by convention {id}-*
|
|
1406
|
+
import re
|
|
1407
|
+
code, stdout, _ = git._run_git(["branch", "--format=%(refname:short)"], project_root)
|
|
1408
|
+
if code == 0:
|
|
1409
|
+
for branch in stdout.splitlines():
|
|
1410
|
+
branch = branch.strip()
|
|
1411
|
+
if re.match(rf"{re.escape(issue_id)}-", branch, re.IGNORECASE):
|
|
1412
|
+
source_ref = branch
|
|
1413
|
+
break
|
|
1072
1414
|
|
|
1073
|
-
# If local metadata doesn't have isolation ref, we might be stuck.
|
|
1074
|
-
# But let's assume valid workflow.
|
|
1075
|
-
if not source_ref:
|
|
1076
|
-
# Try to find a branch starting with feat/{id} or {id}
|
|
1077
|
-
# This is a bit weak, needs better implementation in 'git' or 'issue' module
|
|
1078
|
-
# For now, if we can't find it, we error.
|
|
1079
|
-
pass
|
|
1080
|
-
|
|
1081
|
-
if not source_ref or not git.branch_exists(project_root, source_ref):
|
|
1082
|
-
# Fallback: maybe we are currently ON the feature branch?
|
|
1083
|
-
# If so, source_ref should be current. But we expect to call this from MAIN.
|
|
1084
|
-
pass
|
|
1085
|
-
|
|
1086
1415
|
if not source_ref:
|
|
1087
|
-
|
|
1416
|
+
# No branch found - files should already be in current branch
|
|
1417
|
+
# This is valid for direct workflow (no feature branch created)
|
|
1418
|
+
# Nothing to merge, files are already in place
|
|
1419
|
+
return issue.files
|
|
1088
1420
|
|
|
1089
1421
|
if not git.branch_exists(project_root, source_ref):
|
|
1090
1422
|
raise RuntimeError(f"Source branch {source_ref} does not exist.")
|
|
@@ -1115,10 +1447,20 @@ def merge_issue_changes(
|
|
|
1115
1447
|
if not issue.files:
|
|
1116
1448
|
return []
|
|
1117
1449
|
|
|
1118
|
-
#
|
|
1119
|
-
#
|
|
1120
|
-
|
|
1450
|
+
# Compatibility: Decode any legacy escaped paths in files field
|
|
1451
|
+
# This handles files stored before the _unquote_git_path fix was applied
|
|
1452
|
+
decoded_files = [_unquote_git_path(f) for f in issue.files]
|
|
1453
|
+
|
|
1454
|
+
if not decoded_files:
|
|
1455
|
+
return []
|
|
1456
|
+
|
|
1457
|
+
# Identify the issue file path
|
|
1458
|
+
relative_issue_path = str(path.relative_to(project_root))
|
|
1459
|
+
|
|
1460
|
+
# Separate files: issue file vs code files
|
|
1461
|
+
code_files = [f for f in decoded_files if f != relative_issue_path]
|
|
1121
1462
|
|
|
1463
|
+
# 1. Conflict Check (only for code files, issue file skips conflict check)
|
|
1122
1464
|
current_head = git.get_current_branch(project_root)
|
|
1123
1465
|
try:
|
|
1124
1466
|
base = git.get_merge_base(project_root, current_head, source_ref)
|
|
@@ -1126,7 +1468,7 @@ def merge_issue_changes(
|
|
|
1126
1468
|
raise RuntimeError(f"Failed to determine merge base: {e}")
|
|
1127
1469
|
|
|
1128
1470
|
conflicts = []
|
|
1129
|
-
for f in
|
|
1471
|
+
for f in code_files:
|
|
1130
1472
|
# Has main changed this file?
|
|
1131
1473
|
if git.has_diff(project_root, base, current_head, [f]):
|
|
1132
1474
|
# Has feature also changed this file?
|
|
@@ -1141,12 +1483,15 @@ def merge_issue_changes(
|
|
|
1141
1483
|
)
|
|
1142
1484
|
|
|
1143
1485
|
# 2. Perform Atomic Merge (Selective Checkout)
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1486
|
+
# FEAT-0172: Only merge code files, issue file is handled separately
|
|
1487
|
+
files_to_merge = code_files
|
|
1488
|
+
if files_to_merge:
|
|
1489
|
+
try:
|
|
1490
|
+
git.git_checkout_files(project_root, source_ref, files_to_merge)
|
|
1491
|
+
except Exception as e:
|
|
1492
|
+
raise RuntimeError(f"Selective checkout failed: {e}")
|
|
1148
1493
|
|
|
1149
|
-
return
|
|
1494
|
+
return files_to_merge
|
|
1150
1495
|
|
|
1151
1496
|
|
|
1152
1497
|
# Resources
|
|
@@ -26,22 +26,3 @@ def list_domains():
|
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
console.print(table)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@app.command("check")
|
|
32
|
-
def check_domain(domain: str = typer.Argument(..., help="Domain name to check")):
|
|
33
|
-
"""Check if a domain is valid and resolve it."""
|
|
34
|
-
service = DomainService()
|
|
35
|
-
|
|
36
|
-
if service.is_canonical(domain):
|
|
37
|
-
console.print(f"[green]✔ '{domain}' is a canonical domain.[/green]")
|
|
38
|
-
elif service.is_alias(domain):
|
|
39
|
-
canonical = service.get_canonical(domain)
|
|
40
|
-
console.print(f"[yellow]➜ '{domain}' is an alias for '{canonical}'.[/yellow]")
|
|
41
|
-
else:
|
|
42
|
-
if service.config.strict:
|
|
43
|
-
console.print(f"[red]✘ '{domain}' is NOT a valid domain.[/red]")
|
|
44
|
-
else:
|
|
45
|
-
console.print(
|
|
46
|
-
f"[yellow]⚠ '{domain}' is undefined (Strict Mode: OFF).[/yellow]"
|
|
47
|
-
)
|