git-captains-log 0.7.0__tar.gz → 0.7.2__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.
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/.gitignore +7 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/PKG-INFO +2 -2
- git_captains_log-0.7.2/docs/README.md +34 -0
- git_captains_log-0.7.2/docs/ideas/README.md +24 -0
- git_captains_log-0.7.2/docs/roadmap/README.md +15 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/pyproject.toml +1 -3
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/_version.py +2 -2
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/cli.py +5 -10
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/git/git_operations.py +143 -20
- git_captains_log-0.7.2/src/projects/project_finder.py +180 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/stone.py +19 -4
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/update_log.py +6 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/wnext.py +6 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/wtf.py +6 -0
- git_captains_log-0.7.0/src/projects/project_finder.py +0 -64
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/LICENSE +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/README.md +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/__init__.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/app.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/btw.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/cli_logging.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/config/__init__.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/config/config_loader.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/config/config_models.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/__init__.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/entry_formatter.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/entry_models.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/entry_processor.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/git/__init__.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/git/commit_parser.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2/src/hooks}/commit-msg +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2/src/hooks}/commit-msg-package +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/__init__.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_manager.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_models.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_parser.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_writer.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/projects/__init__.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/projects/project_models.py +0 -0
- {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/shortcuts.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Documentation Map
|
|
2
|
+
|
|
3
|
+
This repository uses a docs-as-code layout bootstrapped by [adrlane](https://github.com/koradon/adrlane).
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
| Path | Purpose |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `docs/specs/` | Feature specifications, behavior, and contracts |
|
|
10
|
+
| `docs/plans/` | Implementation plans derived from specs |
|
|
11
|
+
| `docs/adr/` | Architecture and design decision records |
|
|
12
|
+
| `docs/ideas/` | Early concepts that may be promoted to specs |
|
|
13
|
+
| `docs/roadmap/` | Now / Next / Later horizons for future initiatives |
|
|
14
|
+
| `docs/llm/` | Agent-facing documentation contract and templates |
|
|
15
|
+
|
|
16
|
+
## How this documentation grows
|
|
17
|
+
|
|
18
|
+
`adrlane init` creates a minimal core and does not predict future project shape.
|
|
19
|
+
|
|
20
|
+
When the project gains a new, recurring documentation need (for example CLI reference, runbooks, or API reference), the agent should:
|
|
21
|
+
|
|
22
|
+
1. Add a new top-level folder under `docs/` when the content does not fit `specs/`, `plans/`, `adr/`, `ideas/`, or `roadmap/`.
|
|
23
|
+
2. Copy a starter template from `docs/llm/templates/` and adapt it.
|
|
24
|
+
3. Update this file to document the new section.
|
|
25
|
+
|
|
26
|
+
Do not create empty folders "for later". Grow the tree only when there is real content to place there.
|
|
27
|
+
|
|
28
|
+
Release history lives in Git and release tooling, not in `docs/`.
|
|
29
|
+
|
|
30
|
+
For the full growth model, see the [adrlane documentation model](https://github.com/koradon/adrlane#documentation-model).
|
|
31
|
+
|
|
32
|
+
## For agents
|
|
33
|
+
|
|
34
|
+
Read `docs/llm/AGENT_PROTOCOL.md` first. It defines the doc workflow (`idea -> spec -> plan`) and how to extend the documentation consistently.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Ideas
|
|
2
|
+
|
|
3
|
+
Ideas are early, uncommitted concepts. The goal is to decide whether a concept deserves further investment.
|
|
4
|
+
|
|
5
|
+
An idea can flow into:
|
|
6
|
+
|
|
7
|
+
- a **Spec** (`docs/specs/`) when the desired behavior and contracts become clear
|
|
8
|
+
- a **Plan** (`docs/plans/`) when implementation needs multiple steps or sessions
|
|
9
|
+
|
|
10
|
+
If an idea does not pass evaluation, the agent may set `## Status` to `rejected` (and optionally link what was decided instead).
|
|
11
|
+
|
|
12
|
+
## Common flow
|
|
13
|
+
|
|
14
|
+
1. Create/update an idea in `docs/ideas/`.
|
|
15
|
+
2. When the behavior is clear enough, create a spec in `docs/specs/` and link them in `## Related`.
|
|
16
|
+
3. When implementation is non-trivial, create a plan in `docs/plans/` and link it from the spec.
|
|
17
|
+
|
|
18
|
+
## Template
|
|
19
|
+
|
|
20
|
+
Copy and adapt `docs/llm/templates/idea.md`.
|
|
21
|
+
|
|
22
|
+
## How to stay agnostic
|
|
23
|
+
|
|
24
|
+
Ideas describe outcomes and constraints without assuming a specific stack (web, backend, CLI, or binaries).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Roadmap (Now / Next / Later)
|
|
2
|
+
|
|
3
|
+
The roadmap holds future initiatives without committing to exact dates.
|
|
4
|
+
|
|
5
|
+
- **Now**: active work or work starting soon (high confidence)
|
|
6
|
+
- **Next**: upcoming work with high confidence, but not started yet
|
|
7
|
+
- **Later**: speculative bets or ideas pending further validation
|
|
8
|
+
|
|
9
|
+
### Update semantics
|
|
10
|
+
|
|
11
|
+
- Add items when there is enough clarity to evaluate them.
|
|
12
|
+
- Link roadmap items to the related **Idea**, **Spec**, and/or **Plan** documents.
|
|
13
|
+
- Move items between horizons when confidence changes.
|
|
14
|
+
|
|
15
|
+
If the project needs more detailed horizon pages, the agent can add them under `docs/roadmap/` using `docs/llm/templates/roadmap.md`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "git-captains-log"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
# dynamic = ["version"] # Temporarily disabled for release
|
|
9
9
|
description = "Automatically aggregate git commit messages daily into markdown logs"
|
|
10
10
|
authors = [
|
|
@@ -36,8 +36,6 @@ packages = ["src"]
|
|
|
36
36
|
[tool.hatch.build.targets.sdist]
|
|
37
37
|
include = [
|
|
38
38
|
"src/",
|
|
39
|
-
"commit-msg",
|
|
40
|
-
"commit-msg-package",
|
|
41
39
|
"README.md",
|
|
42
40
|
"LICENSE",
|
|
43
41
|
]
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.7.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 7,
|
|
21
|
+
__version__ = version = '0.7.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 7, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -75,17 +75,12 @@ def setup():
|
|
|
75
75
|
# Install commit-msg hook
|
|
76
76
|
print("Installing Git commit-msg hook...")
|
|
77
77
|
|
|
78
|
-
#
|
|
79
|
-
|
|
78
|
+
# Hook templates ship inside the package (src/hooks/), so this works the
|
|
79
|
+
# same way for an editable/source install and a real wheel install.
|
|
80
|
+
hooks_dir = package_dir / "hooks"
|
|
81
|
+
commit_msg_source = hooks_dir / "commit-msg-package"
|
|
80
82
|
if not commit_msg_source.exists():
|
|
81
|
-
|
|
82
|
-
commit_msg_source = package_dir.parent / "commit-msg"
|
|
83
|
-
if not commit_msg_source.exists():
|
|
84
|
-
# Try alternate location (when installed from wheel)
|
|
85
|
-
commit_msg_source = Path(__file__).parent.parent / "commit-msg-package"
|
|
86
|
-
if not commit_msg_source.exists():
|
|
87
|
-
# Final fallback
|
|
88
|
-
commit_msg_source = Path(__file__).parent.parent / "commit-msg"
|
|
83
|
+
commit_msg_source = hooks_dir / "commit-msg"
|
|
89
84
|
|
|
90
85
|
commit_msg_dest = git_hooks_dir / "commit-msg"
|
|
91
86
|
if commit_msg_source.exists():
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
"""Git operations for Captain's Log."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import subprocess
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
7
|
+
# Git points these at the invoking repository (and, from a worktree, at its
|
|
8
|
+
# private worktree gitdir/index) via the environment for the whole duration
|
|
9
|
+
# of a hook. `git -C <other_repo>` only changes the directory used to search
|
|
10
|
+
# for a repo and does not override an already-set GIT_DIR/GIT_INDEX_FILE, so
|
|
11
|
+
# without stripping these, git commands "targeting" the log repo actually
|
|
12
|
+
# operate on whichever repo/worktree triggered the hook.
|
|
13
|
+
_GIT_ENV_VARS_TO_STRIP = (
|
|
14
|
+
"GIT_DIR",
|
|
15
|
+
"GIT_INDEX_FILE",
|
|
16
|
+
"GIT_WORK_TREE",
|
|
17
|
+
"GIT_COMMON_DIR",
|
|
18
|
+
)
|
|
19
|
+
|
|
6
20
|
|
|
7
21
|
class GitOperations:
|
|
8
22
|
"""Handles git operations for the log repository."""
|
|
9
23
|
|
|
24
|
+
# How many times to pull-and-retry a rejected push before giving up.
|
|
25
|
+
MAX_PUSH_RETRIES = 3
|
|
26
|
+
|
|
27
|
+
# Git attribute that makes concurrent same-section appends to the log's
|
|
28
|
+
# markdown files merge cleanly (keep every unique line from both sides)
|
|
29
|
+
# instead of producing conflict markers, since entries are always
|
|
30
|
+
# appended and never edited in place.
|
|
31
|
+
MERGE_UNION_ATTRIBUTE = "*.md merge=union"
|
|
32
|
+
|
|
10
33
|
def __init__(self, repo_path: Path):
|
|
11
34
|
"""Initialize with repository path.
|
|
12
35
|
|
|
@@ -15,6 +38,19 @@ class GitOperations:
|
|
|
15
38
|
"""
|
|
16
39
|
self.repo_path = repo_path
|
|
17
40
|
|
|
41
|
+
def _run_git(self, args: list, **kwargs) -> subprocess.CompletedProcess:
|
|
42
|
+
"""Run a git command scoped to repo_path, immune to inherited repo env vars.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
args: Arguments to pass after "git" (e.g. ["-C", path, "status"])
|
|
46
|
+
**kwargs: Additional keyword arguments for subprocess.run
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The completed process
|
|
50
|
+
"""
|
|
51
|
+
env = {k: v for k, v in os.environ.items() if k not in _GIT_ENV_VARS_TO_STRIP}
|
|
52
|
+
return subprocess.run(["git"] + args, env=env, **kwargs)
|
|
53
|
+
|
|
18
54
|
def has_changes(self) -> bool:
|
|
19
55
|
"""Check if there are any uncommitted changes in the repository.
|
|
20
56
|
|
|
@@ -22,8 +58,8 @@ class GitOperations:
|
|
|
22
58
|
True if there are changes, False otherwise
|
|
23
59
|
"""
|
|
24
60
|
try:
|
|
25
|
-
result =
|
|
26
|
-
["
|
|
61
|
+
result = self._run_git(
|
|
62
|
+
["-C", str(self.repo_path), "status", "--porcelain"],
|
|
27
63
|
capture_output=True,
|
|
28
64
|
text=True,
|
|
29
65
|
check=True,
|
|
@@ -56,8 +92,8 @@ class GitOperations:
|
|
|
56
92
|
"""
|
|
57
93
|
try:
|
|
58
94
|
relative_path = file_path.relative_to(self.repo_path)
|
|
59
|
-
|
|
60
|
-
["
|
|
95
|
+
self._run_git(
|
|
96
|
+
["-C", str(self.repo_path), "add", str(relative_path)],
|
|
61
97
|
check=True,
|
|
62
98
|
capture_output=True,
|
|
63
99
|
text=True,
|
|
@@ -83,8 +119,8 @@ class GitOperations:
|
|
|
83
119
|
# Get list of changed files from git status
|
|
84
120
|
# Note: git status --porcelain outputs all files (no truncation),
|
|
85
121
|
# but subprocess.run with capture_output=True buffers all output in memory
|
|
86
|
-
status_result =
|
|
87
|
-
["
|
|
122
|
+
status_result = self._run_git(
|
|
123
|
+
["-C", str(self.repo_path), "status", "--porcelain"],
|
|
88
124
|
check=True,
|
|
89
125
|
capture_output=True,
|
|
90
126
|
text=True,
|
|
@@ -178,8 +214,8 @@ class GitOperations:
|
|
|
178
214
|
# Add individually if batch would be too long or only one file
|
|
179
215
|
for path in batch:
|
|
180
216
|
try:
|
|
181
|
-
|
|
182
|
-
["
|
|
217
|
+
self._run_git(
|
|
218
|
+
["-C", str(self.repo_path), "add", path],
|
|
183
219
|
check=True,
|
|
184
220
|
capture_output=True,
|
|
185
221
|
text=True,
|
|
@@ -192,8 +228,8 @@ class GitOperations:
|
|
|
192
228
|
else:
|
|
193
229
|
# Batch add multiple files at once
|
|
194
230
|
try:
|
|
195
|
-
|
|
196
|
-
["
|
|
231
|
+
self._run_git(
|
|
232
|
+
["-C", str(self.repo_path), "add"] + batch,
|
|
197
233
|
check=True,
|
|
198
234
|
capture_output=True,
|
|
199
235
|
text=True,
|
|
@@ -220,8 +256,8 @@ class GitOperations:
|
|
|
220
256
|
True if successful, False otherwise
|
|
221
257
|
"""
|
|
222
258
|
try:
|
|
223
|
-
|
|
224
|
-
["
|
|
259
|
+
self._run_git(
|
|
260
|
+
["-C", str(self.repo_path), "commit", "-m", message],
|
|
225
261
|
check=True,
|
|
226
262
|
capture_output=True,
|
|
227
263
|
text=True,
|
|
@@ -237,8 +273,8 @@ class GitOperations:
|
|
|
237
273
|
True if successful, False otherwise
|
|
238
274
|
"""
|
|
239
275
|
try:
|
|
240
|
-
|
|
241
|
-
["
|
|
276
|
+
self._run_git(
|
|
277
|
+
["-C", str(self.repo_path), "push"],
|
|
242
278
|
check=True,
|
|
243
279
|
capture_output=True,
|
|
244
280
|
text=True,
|
|
@@ -247,6 +283,81 @@ class GitOperations:
|
|
|
247
283
|
except subprocess.CalledProcessError:
|
|
248
284
|
return False
|
|
249
285
|
|
|
286
|
+
def pull(self) -> bool:
|
|
287
|
+
"""Pull changes from the remote, merging into the current branch.
|
|
288
|
+
|
|
289
|
+
Uses a plain merge (not rebase): on conflict the working tree is left
|
|
290
|
+
in a normal "unmerged" state that a clean `git merge --abort` can
|
|
291
|
+
always recover from, which matters since this runs unattended from a
|
|
292
|
+
git hook. If a conflict happens anyway, the merge is aborted so we
|
|
293
|
+
never leave conflict markers sitting in a log file.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
True if the pull succeeded (including a no-op pull), False if
|
|
297
|
+
there was nothing to pull from (no remote/offline) or a conflict
|
|
298
|
+
had to be aborted.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
self._run_git(
|
|
302
|
+
["-C", str(self.repo_path), "pull", "--no-rebase"],
|
|
303
|
+
check=True,
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True,
|
|
306
|
+
)
|
|
307
|
+
return True
|
|
308
|
+
except subprocess.CalledProcessError as e:
|
|
309
|
+
output = f"{e.stdout or ''}{e.stderr or ''}"
|
|
310
|
+
if "CONFLICT" in output:
|
|
311
|
+
self._run_git(
|
|
312
|
+
["-C", str(self.repo_path), "merge", "--abort"],
|
|
313
|
+
capture_output=True,
|
|
314
|
+
text=True,
|
|
315
|
+
)
|
|
316
|
+
print(
|
|
317
|
+
"Warning: Pull produced a merge conflict, aborted the merge. "
|
|
318
|
+
"Resolve manually by running 'git pull' in the log repository."
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
print(f"Warning: Failed to pull changes: {e.stderr or e}")
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
def ensure_merge_driver(self) -> bool:
|
|
325
|
+
"""Ensure the log repo's .gitattributes enables union merging for .md files.
|
|
326
|
+
|
|
327
|
+
Committing this once (and letting it propagate to every machine via
|
|
328
|
+
the log repo itself) means concurrent appends to the same day's file
|
|
329
|
+
from two machines merge automatically instead of conflicting.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
True if .gitattributes was created or updated, False if it
|
|
333
|
+
already had the attribute or couldn't be read/written.
|
|
334
|
+
"""
|
|
335
|
+
gitattributes_path = self.repo_path / ".gitattributes"
|
|
336
|
+
try:
|
|
337
|
+
existing = (
|
|
338
|
+
gitattributes_path.read_text(encoding="utf-8")
|
|
339
|
+
if gitattributes_path.exists()
|
|
340
|
+
else ""
|
|
341
|
+
)
|
|
342
|
+
except OSError:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
if self.MERGE_UNION_ATTRIBUTE in existing:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
new_content = existing
|
|
349
|
+
if new_content and not new_content.endswith("\n"):
|
|
350
|
+
new_content += "\n"
|
|
351
|
+
new_content += self.MERGE_UNION_ATTRIBUTE + "\n"
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
gitattributes_path.write_text(new_content, encoding="utf-8")
|
|
355
|
+
except OSError:
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
self.add_file(gitattributes_path)
|
|
359
|
+
return True
|
|
360
|
+
|
|
250
361
|
def commit_and_push(self, commit_message: str) -> bool:
|
|
251
362
|
"""Perform the complete commit and push workflow.
|
|
252
363
|
|
|
@@ -262,6 +373,8 @@ class GitOperations:
|
|
|
262
373
|
print("Warning: Git lock files found, skipping operations")
|
|
263
374
|
return False
|
|
264
375
|
|
|
376
|
+
self.ensure_merge_driver()
|
|
377
|
+
|
|
265
378
|
# Check if there are any changes to commit
|
|
266
379
|
if not self.has_changes():
|
|
267
380
|
print("No changes to commit, skipping git operations")
|
|
@@ -277,13 +390,23 @@ class GitOperations:
|
|
|
277
390
|
print("Warning: Failed to commit changes")
|
|
278
391
|
return False
|
|
279
392
|
|
|
280
|
-
# Push
|
|
281
|
-
if
|
|
282
|
-
print("
|
|
283
|
-
return
|
|
393
|
+
# Push, retrying with a pull in between if the remote moved on
|
|
394
|
+
if self.push():
|
|
395
|
+
print("Successfully committed and pushed log updates")
|
|
396
|
+
return True
|
|
284
397
|
|
|
285
|
-
|
|
286
|
-
|
|
398
|
+
for attempt in range(1, self.MAX_PUSH_RETRIES + 1):
|
|
399
|
+
print(
|
|
400
|
+
f"Warning: Push rejected, pulling and retrying "
|
|
401
|
+
f"({attempt}/{self.MAX_PUSH_RETRIES})..."
|
|
402
|
+
)
|
|
403
|
+
self.pull()
|
|
404
|
+
if self.push():
|
|
405
|
+
print("Successfully committed and pushed log updates")
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
print("Warning: Failed to push changes")
|
|
409
|
+
return False
|
|
287
410
|
|
|
288
411
|
except Exception as e:
|
|
289
412
|
print(f"Warning: Unexpected error during git operations: {e}")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Project discovery functionality for Captain's Log."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from src.config.config_models import Config, ProjectConfig
|
|
8
|
+
from src.projects.project_models import ProjectInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectFinder:
|
|
12
|
+
"""Handles finding and identifying projects from repository paths."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: Config):
|
|
15
|
+
"""Initialize with configuration."""
|
|
16
|
+
self.config = config
|
|
17
|
+
|
|
18
|
+
def find_project(self, repo_path: str) -> ProjectInfo:
|
|
19
|
+
"""Find project information from a repository path.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
repo_path: Path to the repository directory
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
ProjectInfo with project name and configuration
|
|
26
|
+
"""
|
|
27
|
+
repo_path_abs = Path(repo_path).resolve()
|
|
28
|
+
|
|
29
|
+
# Check configured projects first
|
|
30
|
+
for project_name, project_config in self.config.projects.items():
|
|
31
|
+
if project_config.root is None:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
root_abs = project_config.root.resolve()
|
|
35
|
+
if root_abs in repo_path_abs.parents or root_abs == repo_path_abs:
|
|
36
|
+
return ProjectInfo(
|
|
37
|
+
name=project_name, config=project_config, base_dir=root_abs
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# A repo checked out elsewhere (e.g. a treehouse/other pooled
|
|
41
|
+
# worktree, which never lives under the configured root) still
|
|
42
|
+
# belongs to this project if its directory name matches one of
|
|
43
|
+
# root's own nested repos, so umbrella dirs like
|
|
44
|
+
# ~/git/tripper/{facts-service,tripper-android-app} keep
|
|
45
|
+
# grouping under "tripper" even when checked out elsewhere.
|
|
46
|
+
# Matching by name alone would silently misattribute an
|
|
47
|
+
# unrelated repo that happens to share a common name (e.g.
|
|
48
|
+
# "docs" or "api"), so we also require the two checkouts to
|
|
49
|
+
# agree on their origin remote whenever both expose one.
|
|
50
|
+
nested_repo = root_abs / repo_path_abs.name
|
|
51
|
+
if (nested_repo / ".git").exists() and self._same_remote(
|
|
52
|
+
repo_path_abs, nested_repo
|
|
53
|
+
):
|
|
54
|
+
return ProjectInfo(
|
|
55
|
+
name=project_name, config=project_config, base_dir=root_abs
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Fallback: use repository name as project name
|
|
59
|
+
project_name = repo_path_abs.name
|
|
60
|
+
fallback_config = ProjectConfig(root=repo_path_abs)
|
|
61
|
+
|
|
62
|
+
return ProjectInfo(
|
|
63
|
+
name=project_name, config=fallback_config, base_dir=repo_path_abs
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _same_remote(self, repo_a: Path, repo_b: Path) -> bool:
|
|
67
|
+
"""Check whether two repos agree on their git origin remote.
|
|
68
|
+
|
|
69
|
+
Repos with no resolvable remote (e.g. never pushed, or a local-only
|
|
70
|
+
checkout) are treated as matching, since name-based matching is the
|
|
71
|
+
best signal available for them. Repos that both expose a remote
|
|
72
|
+
must agree, so two unrelated repos sharing a common directory name
|
|
73
|
+
(e.g. "docs", "api") aren't silently merged into the same project.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
repo_a: First repository directory
|
|
77
|
+
repo_b: Second repository directory
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if the repos are the same project by remote (or lack
|
|
81
|
+
enough information to tell them apart), False if they have
|
|
82
|
+
conflicting remotes
|
|
83
|
+
"""
|
|
84
|
+
remote_a = self._git_remote_url(repo_a)
|
|
85
|
+
remote_b = self._git_remote_url(repo_b)
|
|
86
|
+
|
|
87
|
+
if remote_a is None or remote_b is None:
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
return remote_a == remote_b
|
|
91
|
+
|
|
92
|
+
def _git_remote_url(self, repo: Path) -> Optional[str]:
|
|
93
|
+
"""Read the "origin" remote URL from a repo's git config, if any.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
repo: Path to the git repository (containing a `.git` dir or,
|
|
97
|
+
for a worktree checkout, a `.git` file pointing elsewhere)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The origin remote URL, or None if unavailable
|
|
101
|
+
"""
|
|
102
|
+
git_dir = self._resolve_common_git_dir(repo)
|
|
103
|
+
if git_dir is None:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
config_path = git_dir / "config"
|
|
107
|
+
if not config_path.is_file():
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
parser = configparser.RawConfigParser()
|
|
111
|
+
try:
|
|
112
|
+
parser.read(config_path)
|
|
113
|
+
return parser.get('remote "origin"', "url", fallback=None)
|
|
114
|
+
except configparser.Error:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def _resolve_common_git_dir(self, repo: Path) -> Optional[Path]:
|
|
118
|
+
"""Resolve the shared git directory a repo checkout belongs to.
|
|
119
|
+
|
|
120
|
+
For a regular checkout this is just `<repo>/.git`. For a `git
|
|
121
|
+
worktree` checkout, `.git` is a file containing a `gitdir:` pointer
|
|
122
|
+
into the main repo's `.git/worktrees/<name>` directory, which in
|
|
123
|
+
turn has a `commondir` file pointing at the actual shared git dir
|
|
124
|
+
(where `config` and remotes live) - so both need to be followed.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
repo: Path to the repository directory
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The resolved git directory, or None if it can't be determined
|
|
131
|
+
"""
|
|
132
|
+
dot_git = repo / ".git"
|
|
133
|
+
|
|
134
|
+
if dot_git.is_dir():
|
|
135
|
+
return dot_git
|
|
136
|
+
|
|
137
|
+
if not dot_git.is_file():
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
contents = dot_git.read_text().strip()
|
|
142
|
+
except OSError:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
if not contents.startswith("gitdir:"):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
worktree_git_dir = Path(contents.split(":", 1)[1].strip())
|
|
149
|
+
if not worktree_git_dir.is_absolute():
|
|
150
|
+
worktree_git_dir = (repo / worktree_git_dir).resolve()
|
|
151
|
+
|
|
152
|
+
commondir_file = worktree_git_dir / "commondir"
|
|
153
|
+
if not commondir_file.is_file():
|
|
154
|
+
return worktree_git_dir
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
common_dir_rel = commondir_file.read_text().strip()
|
|
158
|
+
except OSError:
|
|
159
|
+
return worktree_git_dir
|
|
160
|
+
|
|
161
|
+
return (worktree_git_dir / common_dir_rel).resolve()
|
|
162
|
+
|
|
163
|
+
def get_project_by_name(self, project_name: str) -> Optional[ProjectInfo]:
|
|
164
|
+
"""Get project information by name.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
project_name: Name of the project
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
ProjectInfo if found, None otherwise
|
|
171
|
+
"""
|
|
172
|
+
project_config = self.config.projects.get(project_name)
|
|
173
|
+
if project_config is None:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
return ProjectInfo(
|
|
177
|
+
name=project_name,
|
|
178
|
+
config=project_config,
|
|
179
|
+
base_dir=project_config.root or Path.cwd(),
|
|
180
|
+
)
|
|
@@ -95,12 +95,17 @@ def append_milestone_entry(
|
|
|
95
95
|
file_path.write_text(content, encoding="utf-8")
|
|
96
96
|
|
|
97
97
|
|
|
98
|
-
def
|
|
99
|
-
"""
|
|
98
|
+
def resolve_project() -> tuple[Config, ProjectInfo]:
|
|
99
|
+
"""Resolve the config and project for the current working directory."""
|
|
100
100
|
config = load_config()
|
|
101
101
|
project_finder = ProjectFinder(config)
|
|
102
102
|
cwd = Path.cwd()
|
|
103
103
|
project = project_finder.find_project(str(cwd))
|
|
104
|
+
return config, project
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_milestone_context(config: Config, project: ProjectInfo) -> MilestoneContext:
|
|
108
|
+
"""Build the milestone context for an already-resolved config and project."""
|
|
104
109
|
today = date.today()
|
|
105
110
|
file_path = get_milestone_file_path(config, project, today)
|
|
106
111
|
return MilestoneContext(
|
|
@@ -110,13 +115,23 @@ def build_milestone_context() -> MilestoneContext:
|
|
|
110
115
|
|
|
111
116
|
def add_milestone_entry(entry_text: str) -> None:
|
|
112
117
|
"""High-level API to add a milestone entry for the current project."""
|
|
113
|
-
|
|
118
|
+
config, project = resolve_project()
|
|
119
|
+
|
|
120
|
+
# Pull remote changes before deciding where the entry goes, since the
|
|
121
|
+
# destination (flat milestone.md vs <year>/milestone.md) depends on
|
|
122
|
+
# whether a <year>/ directory already exists locally. Pulling first
|
|
123
|
+
# ensures a rollover pushed from another machine (or directly on GitHub)
|
|
124
|
+
# is reflected in that decision, instead of writing to a now-stale path.
|
|
125
|
+
log_repo_path = project.log_repo or config.global_log_repo
|
|
126
|
+
if log_repo_path is not None:
|
|
127
|
+
GitOperations(log_repo_path).pull()
|
|
128
|
+
|
|
129
|
+
ctx = build_milestone_context(config, project)
|
|
114
130
|
emoji = random.choice(EMOJIS)
|
|
115
131
|
|
|
116
132
|
append_milestone_entry(ctx.file_path, ctx.log_date, entry_text, emoji)
|
|
117
133
|
|
|
118
134
|
# Commit and push if we have a git repository backing the logs
|
|
119
|
-
log_repo_path = ctx.project.log_repo or ctx.config.global_log_repo
|
|
120
135
|
if log_repo_path is not None:
|
|
121
136
|
git_ops = GitOperations(log_repo_path)
|
|
122
137
|
commit_message = (
|
|
@@ -152,6 +152,12 @@ def main():
|
|
|
152
152
|
log_manager = LogManager(config)
|
|
153
153
|
log_info = log_manager.get_log_file_info(project)
|
|
154
154
|
|
|
155
|
+
# Pull remote changes before reading the log, so a push made from
|
|
156
|
+
# another machine (or directly on GitHub) is already reflected in
|
|
157
|
+
# what we load and merge into below.
|
|
158
|
+
if log_info.has_git_repo and log_info.log_repo_path:
|
|
159
|
+
GitOperations(log_info.log_repo_path).pull()
|
|
160
|
+
|
|
155
161
|
# Load existing log data
|
|
156
162
|
log_data = log_manager.load_log(log_info)
|
|
157
163
|
|
|
@@ -42,6 +42,12 @@ def add_what_next_entry(entry_text: str, project_name: Optional[str], use_other:
|
|
|
42
42
|
log_manager = LogManager(config)
|
|
43
43
|
log_info = log_manager.get_log_file_info(project)
|
|
44
44
|
|
|
45
|
+
# Pull remote changes before reading the log, so a push made from
|
|
46
|
+
# another machine (or directly on GitHub) is already reflected in
|
|
47
|
+
# what we load and merge into below.
|
|
48
|
+
if log_info.has_git_repo and log_info.log_repo_path:
|
|
49
|
+
GitOperations(log_info.log_repo_path).pull()
|
|
50
|
+
|
|
45
51
|
# Load existing log data
|
|
46
52
|
log_data = log_manager.load_log(log_info)
|
|
47
53
|
|
|
@@ -37,6 +37,12 @@ def add_wtf_entry(entry_text: str):
|
|
|
37
37
|
log_manager = LogManager(config)
|
|
38
38
|
log_info = log_manager.get_log_file_info(project)
|
|
39
39
|
|
|
40
|
+
# Pull remote changes before reading the log, so a push made from
|
|
41
|
+
# another machine (or directly on GitHub) is already reflected in
|
|
42
|
+
# what we load and merge into below.
|
|
43
|
+
if log_info.has_git_repo and log_info.log_repo_path:
|
|
44
|
+
GitOperations(log_info.log_repo_path).pull()
|
|
45
|
+
|
|
40
46
|
# Load existing log data
|
|
41
47
|
log_data = log_manager.load_log(log_info)
|
|
42
48
|
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Project discovery functionality for Captain's Log."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Optional
|
|
5
|
-
|
|
6
|
-
from src.config.config_models import Config, ProjectConfig
|
|
7
|
-
from src.projects.project_models import ProjectInfo
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ProjectFinder:
|
|
11
|
-
"""Handles finding and identifying projects from repository paths."""
|
|
12
|
-
|
|
13
|
-
def __init__(self, config: Config):
|
|
14
|
-
"""Initialize with configuration."""
|
|
15
|
-
self.config = config
|
|
16
|
-
|
|
17
|
-
def find_project(self, repo_path: str) -> ProjectInfo:
|
|
18
|
-
"""Find project information from a repository path.
|
|
19
|
-
|
|
20
|
-
Args:
|
|
21
|
-
repo_path: Path to the repository directory
|
|
22
|
-
|
|
23
|
-
Returns:
|
|
24
|
-
ProjectInfo with project name and configuration
|
|
25
|
-
"""
|
|
26
|
-
repo_path_abs = Path(repo_path).resolve()
|
|
27
|
-
|
|
28
|
-
# Check configured projects first
|
|
29
|
-
for project_name, project_config in self.config.projects.items():
|
|
30
|
-
if project_config.root is None:
|
|
31
|
-
continue
|
|
32
|
-
|
|
33
|
-
root_abs = project_config.root.resolve()
|
|
34
|
-
if root_abs in repo_path_abs.parents or root_abs == repo_path_abs:
|
|
35
|
-
return ProjectInfo(
|
|
36
|
-
name=project_name, config=project_config, base_dir=root_abs
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
# Fallback: use repository name as project name
|
|
40
|
-
project_name = repo_path_abs.name
|
|
41
|
-
fallback_config = ProjectConfig(root=repo_path_abs)
|
|
42
|
-
|
|
43
|
-
return ProjectInfo(
|
|
44
|
-
name=project_name, config=fallback_config, base_dir=repo_path_abs
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
def get_project_by_name(self, project_name: str) -> Optional[ProjectInfo]:
|
|
48
|
-
"""Get project information by name.
|
|
49
|
-
|
|
50
|
-
Args:
|
|
51
|
-
project_name: Name of the project
|
|
52
|
-
|
|
53
|
-
Returns:
|
|
54
|
-
ProjectInfo if found, None otherwise
|
|
55
|
-
"""
|
|
56
|
-
project_config = self.config.projects.get(project_name)
|
|
57
|
-
if project_config is None:
|
|
58
|
-
return None
|
|
59
|
-
|
|
60
|
-
return ProjectInfo(
|
|
61
|
-
name=project_name,
|
|
62
|
-
config=project_config,
|
|
63
|
-
base_dir=project_config.root or Path.cwd(),
|
|
64
|
-
)
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|