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.
Files changed (40) hide show
  1. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/.gitignore +7 -0
  2. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/PKG-INFO +2 -2
  3. git_captains_log-0.7.2/docs/README.md +34 -0
  4. git_captains_log-0.7.2/docs/ideas/README.md +24 -0
  5. git_captains_log-0.7.2/docs/roadmap/README.md +15 -0
  6. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/pyproject.toml +1 -3
  7. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/_version.py +2 -2
  8. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/cli.py +5 -10
  9. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/git/git_operations.py +143 -20
  10. git_captains_log-0.7.2/src/projects/project_finder.py +180 -0
  11. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/stone.py +19 -4
  12. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/update_log.py +6 -0
  13. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/wnext.py +6 -0
  14. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/wtf.py +6 -0
  15. git_captains_log-0.7.0/src/projects/project_finder.py +0 -64
  16. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/LICENSE +0 -0
  17. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/README.md +0 -0
  18. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/__init__.py +0 -0
  19. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/app.py +0 -0
  20. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/btw.py +0 -0
  21. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/cli_logging.py +0 -0
  22. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/config/__init__.py +0 -0
  23. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/config/config_loader.py +0 -0
  24. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/config/config_models.py +0 -0
  25. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/__init__.py +0 -0
  26. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/entry_formatter.py +0 -0
  27. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/entry_models.py +0 -0
  28. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/entries/entry_processor.py +0 -0
  29. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/git/__init__.py +0 -0
  30. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/git/commit_parser.py +0 -0
  31. {git_captains_log-0.7.0 → git_captains_log-0.7.2/src/hooks}/commit-msg +0 -0
  32. {git_captains_log-0.7.0 → git_captains_log-0.7.2/src/hooks}/commit-msg-package +0 -0
  33. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/__init__.py +0 -0
  34. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_manager.py +0 -0
  35. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_models.py +0 -0
  36. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_parser.py +0 -0
  37. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/logs/log_writer.py +0 -0
  38. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/projects/__init__.py +0 -0
  39. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/projects/project_models.py +0 -0
  40. {git_captains_log-0.7.0 → git_captains_log-0.7.2}/src/shortcuts.py +0 -0
@@ -159,3 +159,10 @@ Thumbs.db
159
159
 
160
160
  # Planhub
161
161
  .plan/
162
+
163
+ # Lavish
164
+ .lavish/
165
+
166
+ # Mempalace
167
+ mempalace.yaml
168
+ entities.json
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: git-captains-log
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Automatically aggregate git commit messages daily into markdown logs
5
5
  Author: Captain's Log Team
6
6
  License-File: LICENSE
@@ -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.0"
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.0'
22
- __version_tuple__ = version_tuple = (0, 7, 0)
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
- # Look for commit-msg-package (for package installations) first
79
- commit_msg_source = package_dir.parent / "commit-msg-package"
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
- # Fall back to regular commit-msg (for installation script)
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 = subprocess.run(
26
- ["git", "-C", str(self.repo_path), "status", "--porcelain"],
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
- subprocess.run(
60
- ["git", "-C", str(self.repo_path), "add", str(relative_path)],
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 = subprocess.run(
87
- ["git", "-C", str(self.repo_path), "status", "--porcelain"],
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
- subprocess.run(
182
- ["git", "-C", str(self.repo_path), "add", path],
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
- subprocess.run(
196
- ["git", "-C", str(self.repo_path), "add"] + batch,
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
- subprocess.run(
224
- ["git", "-C", str(self.repo_path), "commit", "-m", message],
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
- subprocess.run(
241
- ["git", "-C", str(self.repo_path), "push"],
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 not self.push():
282
- print("Warning: Failed to push changes")
283
- return False
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
- print("Successfully committed and pushed log updates")
286
- return True
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 build_milestone_context() -> MilestoneContext:
99
- """Build the milestone context from the current working directory."""
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
- ctx = build_milestone_context()
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
- )