git-captains-log 0.7.2__tar.gz → 0.7.4__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 (44) hide show
  1. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/PKG-INFO +31 -5
  2. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/README.md +30 -4
  3. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/docs/README.md +1 -0
  4. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/pyproject.toml +1 -1
  5. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/_version.py +2 -2
  6. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/btw.py +6 -1
  7. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/cli.py +35 -60
  8. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/git/git_operations.py +111 -23
  9. git_captains_log-0.7.4/src/hooks/commit-msg +8 -0
  10. git_captains_log-0.7.4/src/hooks/commit-msg-package +8 -0
  11. git_captains_log-0.7.4/src/hooks/post-commit +10 -0
  12. git_captains_log-0.7.4/src/hooks/post-commit-package +9 -0
  13. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/projects/project_finder.py +24 -1
  14. git_captains_log-0.7.4/src/publication.py +132 -0
  15. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/stone.py +4 -1
  16. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/update_log.py +90 -57
  17. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/wnext.py +6 -1
  18. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/wtf.py +6 -1
  19. git_captains_log-0.7.2/src/hooks/commit-msg +0 -31
  20. git_captains_log-0.7.2/src/hooks/commit-msg-package +0 -21
  21. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/.gitignore +0 -0
  22. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/LICENSE +0 -0
  23. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/docs/ideas/README.md +0 -0
  24. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/docs/roadmap/README.md +0 -0
  25. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/__init__.py +0 -0
  26. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/app.py +0 -0
  27. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/cli_logging.py +0 -0
  28. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/config/__init__.py +0 -0
  29. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/config/config_loader.py +0 -0
  30. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/config/config_models.py +0 -0
  31. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/__init__.py +0 -0
  32. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/entry_formatter.py +0 -0
  33. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/entry_models.py +0 -0
  34. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/entry_processor.py +0 -0
  35. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/git/__init__.py +0 -0
  36. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/git/commit_parser.py +0 -0
  37. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/__init__.py +0 -0
  38. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_manager.py +0 -0
  39. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_models.py +0 -0
  40. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_parser.py +0 -0
  41. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_writer.py +0 -0
  42. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/projects/__init__.py +0 -0
  43. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/projects/project_models.py +0 -0
  44. {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/shortcuts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-captains-log
3
- Version: 0.7.2
3
+ Version: 0.7.4
4
4
  Summary: Automatically aggregate git commit messages daily into markdown logs
5
5
  Author: Captain's Log Team
6
6
  License-File: LICENSE
@@ -41,12 +41,17 @@ captains-log setup
41
41
  This will:
42
42
  - Create `~/.captains-log/` for configuration
43
43
  - Create `~/.git-hooks/` for global Git hooks
44
- - Install or update the `commit-msg` hook
44
+ - Install or update the `commit-msg` validation dispatcher and `post-commit` logger
45
45
  - Configure `git config --global core.hooksPath ~/.git-hooks`
46
46
  - Create a default `~/.captains-log/config.yml` if it does not exist
47
47
 
48
48
  After that you can use `btw`, `wtf`, `wnext`, `stone`, and `captains-log` from anywhere in your shell.
49
49
 
50
+ After upgrading an existing installation, run `captains-log setup` again to replace the
51
+ old `commit-msg` logger and install `post-commit`. Logging runs in the background after
52
+ the source commit exists, so each entry records that commit's SHA. Existing pre-commit
53
+ validation wrappers are preserved.
54
+
50
55
  See [INSTALLATION.md](INSTALLATION.md) for more detailed installation and configuration examples.
51
56
 
52
57
  ### From Source (For Development)
@@ -102,7 +107,7 @@ projects:
102
107
 
103
108
  #### Private Repositories
104
109
 
105
- Set `private: true` on a project to prevent its commits from appearing in the daily log. The commit-msg hook still runs but silently skips the logging step, so other features (`btw`, `wtf`, `wnext`, `stone`) remain available.
110
+ Set `private: true` on a project to prevent its commits from appearing in the daily log. The post-commit hook still runs but skips the logging step, so other features (`btw`, `wtf`, `wnext`, `stone`) remain available.
106
111
 
107
112
  You can also set the `CAPTAINS_LOG_PRIVATE` environment variable to override the config value for the current shell session (useful in CI or per-repo `.envrc` files):
108
113
 
@@ -206,7 +211,10 @@ stone "Completed migration to new infrastructure"
206
211
  #### How They Work:
207
212
  - **Smart Project Detection**: Uses the same project detection logic as git commits
208
213
  - If you're in a configured project directory → logs to that project
209
- - If not configured → uses the current directory name as project
214
+ - Clones and worktrees outside configured roots can still join the same project; see
215
+ [project grouping rules](docs/adr/0004-project-grouping-tolerates-pooled-worktrees.md)
216
+ for directory-name matching and equivalent HTTPS/SSH origin URLs
217
+ - Otherwise → uses the repository directory name as project
210
218
  - **Different Sections**:
211
219
  - `btw` entries appear in the "What I did" section under "## other"
212
220
  - `wtf` entries appear in the "What Broke or Got Weird" section
@@ -276,7 +284,25 @@ uv run pytest tests/ -v
276
284
  ### Hook not running
277
285
  - Make sure you've run `captains-log setup` and it completed successfully
278
286
  - Check that `git config --global core.hooksPath` points to `~/.git-hooks`
279
- - Verify the `commit-msg` file exists in `~/.git-hooks/` and is executable
287
+ - Verify `post-commit` exists in `~/.git-hooks/` and is executable
288
+
289
+ ### Entries saved locally but not published
290
+
291
+ - Inspect `~/.captains-log/update-log.log`: each processing line includes timestamp, run ID,
292
+ source repo, SHA, stage, and exit status. `Saved locally; NOT published` means the remote
293
+ has not received the entry; only `Published` confirms publication.
294
+ - Inspect `~/.captains-log/pending/*.json` for durable failure details. `btw`, `wtf`, `wnext`,
295
+ and `stone` warn when pending runs exist and exit 1 with `Saved locally; NOT published`
296
+ if publishing their own entry fails. A log directory that is not a Git repository is a
297
+ normal local save.
298
+ - The next successful automatic run stages the pending local Markdown files together and
299
+ clears saved-run markers for that destination. It also retries previously committed
300
+ entries whose push failed.
301
+ - Git locks are retried briefly. Only unchanged regular locks at least 24 hours old with
302
+ a successful no-holder ownership check are removed automatically. Live or uncertain
303
+ locks remain for manual inspection; missing `lsof` disables automatic recovery.
304
+
305
+ See the [publication behavior and migration specification](docs/specs/20261020-publication-reliability.md).
280
306
 
281
307
  ### Pre-commit integration issues
282
308
  - Ensure you ran `captains-log setup` before `captains-log install-precommit-hooks`
@@ -30,12 +30,17 @@ captains-log setup
30
30
  This will:
31
31
  - Create `~/.captains-log/` for configuration
32
32
  - Create `~/.git-hooks/` for global Git hooks
33
- - Install or update the `commit-msg` hook
33
+ - Install or update the `commit-msg` validation dispatcher and `post-commit` logger
34
34
  - Configure `git config --global core.hooksPath ~/.git-hooks`
35
35
  - Create a default `~/.captains-log/config.yml` if it does not exist
36
36
 
37
37
  After that you can use `btw`, `wtf`, `wnext`, `stone`, and `captains-log` from anywhere in your shell.
38
38
 
39
+ After upgrading an existing installation, run `captains-log setup` again to replace the
40
+ old `commit-msg` logger and install `post-commit`. Logging runs in the background after
41
+ the source commit exists, so each entry records that commit's SHA. Existing pre-commit
42
+ validation wrappers are preserved.
43
+
39
44
  See [INSTALLATION.md](INSTALLATION.md) for more detailed installation and configuration examples.
40
45
 
41
46
  ### From Source (For Development)
@@ -91,7 +96,7 @@ projects:
91
96
 
92
97
  #### Private Repositories
93
98
 
94
- Set `private: true` on a project to prevent its commits from appearing in the daily log. The commit-msg hook still runs but silently skips the logging step, so other features (`btw`, `wtf`, `wnext`, `stone`) remain available.
99
+ Set `private: true` on a project to prevent its commits from appearing in the daily log. The post-commit hook still runs but skips the logging step, so other features (`btw`, `wtf`, `wnext`, `stone`) remain available.
95
100
 
96
101
  You can also set the `CAPTAINS_LOG_PRIVATE` environment variable to override the config value for the current shell session (useful in CI or per-repo `.envrc` files):
97
102
 
@@ -195,7 +200,10 @@ stone "Completed migration to new infrastructure"
195
200
  #### How They Work:
196
201
  - **Smart Project Detection**: Uses the same project detection logic as git commits
197
202
  - If you're in a configured project directory → logs to that project
198
- - If not configured → uses the current directory name as project
203
+ - Clones and worktrees outside configured roots can still join the same project; see
204
+ [project grouping rules](docs/adr/0004-project-grouping-tolerates-pooled-worktrees.md)
205
+ for directory-name matching and equivalent HTTPS/SSH origin URLs
206
+ - Otherwise → uses the repository directory name as project
199
207
  - **Different Sections**:
200
208
  - `btw` entries appear in the "What I did" section under "## other"
201
209
  - `wtf` entries appear in the "What Broke or Got Weird" section
@@ -265,7 +273,25 @@ uv run pytest tests/ -v
265
273
  ### Hook not running
266
274
  - Make sure you've run `captains-log setup` and it completed successfully
267
275
  - Check that `git config --global core.hooksPath` points to `~/.git-hooks`
268
- - Verify the `commit-msg` file exists in `~/.git-hooks/` and is executable
276
+ - Verify `post-commit` exists in `~/.git-hooks/` and is executable
277
+
278
+ ### Entries saved locally but not published
279
+
280
+ - Inspect `~/.captains-log/update-log.log`: each processing line includes timestamp, run ID,
281
+ source repo, SHA, stage, and exit status. `Saved locally; NOT published` means the remote
282
+ has not received the entry; only `Published` confirms publication.
283
+ - Inspect `~/.captains-log/pending/*.json` for durable failure details. `btw`, `wtf`, `wnext`,
284
+ and `stone` warn when pending runs exist and exit 1 with `Saved locally; NOT published`
285
+ if publishing their own entry fails. A log directory that is not a Git repository is a
286
+ normal local save.
287
+ - The next successful automatic run stages the pending local Markdown files together and
288
+ clears saved-run markers for that destination. It also retries previously committed
289
+ entries whose push failed.
290
+ - Git locks are retried briefly. Only unchanged regular locks at least 24 hours old with
291
+ a successful no-holder ownership check are removed automatically. Live or uncertain
292
+ locks remain for manual inspection; missing `lsof` disables automatic recovery.
293
+
294
+ See the [publication behavior and migration specification](docs/specs/20261020-publication-reliability.md).
269
295
 
270
296
  ### Pre-commit integration issues
271
297
  - Ensure you ran `captains-log setup` before `captains-log install-precommit-hooks`
@@ -6,6 +6,7 @@ This repository uses a docs-as-code layout bootstrapped by [adrlane](https://git
6
6
 
7
7
  | Path | Purpose |
8
8
  | --- | --- |
9
+ | `docs/architecture.md` | Current modules, entry points, and publication flow |
9
10
  | `docs/specs/` | Feature specifications, behavior, and contracts |
10
11
  | `docs/plans/` | Implementation plans derived from specs |
11
12
  | `docs/adr/` | Architecture and design decision records |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "git-captains-log"
7
- version = "0.7.2"
7
+ version = "0.7.4"
8
8
  # dynamic = ["version"] # Temporarily disabled for release
9
9
  description = "Automatically aggregate git commit messages daily into markdown logs"
10
10
  authors = [
@@ -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.2'
22
- __version_tuple__ = version_tuple = (0, 7, 2)
21
+ __version__ = version = '0.7.4'
22
+ __version_tuple__ = version_tuple = (0, 7, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -17,6 +17,7 @@ from src.entries import EntryProcessor
17
17
  from src.git import GitOperations
18
18
  from src.logs import LogManager
19
19
  from src.projects import ProjectFinder
20
+ from src.publication import publish_manual_entry, warn_unpublished
20
21
 
21
22
 
22
23
  def add_manual_entry(entry_text: str):
@@ -26,6 +27,7 @@ def add_manual_entry(entry_text: str):
26
27
  entry_text: Text for the manual entry
27
28
  """
28
29
  # Load configuration
30
+ warn_unpublished()
29
31
  config = load_config()
30
32
 
31
33
  # Find project from current working directory
@@ -61,7 +63,10 @@ def add_manual_entry(entry_text: str):
61
63
  if log_info.has_git_repo:
62
64
  git_ops = GitOperations(log_info.log_repo_path)
63
65
  commit_message = f"Add manual entry to {project.name} logs for {date.today().isoformat()}"
64
- git_ops.commit_and_push(commit_message)
66
+ if not publish_manual_entry(
67
+ git_ops, log_info.log_repo_path, commit_message
68
+ ):
69
+ sys.exit(1)
65
70
 
66
71
  cli_logging.success(f"Added entry to {project.name} log: {entry_text}")
67
72
  else:
@@ -5,6 +5,7 @@ Captain's Log CLI - Main command-line interface
5
5
  Provides setup and configuration commands for Captain's Log.
6
6
  """
7
7
 
8
+ import shlex
8
9
  import subprocess
9
10
  import sys
10
11
  from pathlib import Path
@@ -13,33 +14,31 @@ from . import __version__, cli_logging
13
14
 
14
15
 
15
16
  def _render_commit_msg_hook(python_executable: str) -> str:
16
- """Render commit-msg hook content using a specific Python interpreter.
17
+ """Keep commit-message validation separate from post-commit logging.
17
18
 
18
- This avoids relying on `python3` from PATH (which may not have the
19
- package installed, e.g. when `git-captains-log` is installed via pipx).
19
+ Re-running setup replaces the legacy logger while preserving installed
20
+ pre-commit validation wrappers. Captain's Log itself never vetoes a commit.
20
21
  """
21
- # Use bash for compatibility with existing hook and common shells.
22
- py = python_executable.replace('"', '\\"')
23
- return f"""#!/bin/bash
24
-
25
- # Exit on any error
26
- set -e
27
-
28
- # Get repository information
29
- REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
30
- REPO_PATH=$(git rev-parse --show-toplevel)
31
- COMMIT_MSG=$(head -n1 "$1")
32
-
33
- # Get commit SHA (handle both pre-commit and post-commit scenarios)
34
- COMMIT_SHA=$(git rev-parse --verify HEAD 2>/dev/null || echo "no-sha")
35
-
36
- # Run the update script using the installed package
37
- export GIT_HOOK=1
38
- if ! "{py}" -m src.update_log "$REPO_NAME" "$REPO_PATH" "$COMMIT_SHA" "$COMMIT_MSG"; then
39
- echo "Warning: Captain's Log update failed, but commit will continue"
40
- echo "Check the script output above for errors"
41
- exit 0 # Don't fail the commit
22
+ return """#!/bin/sh
23
+ HOOKS_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
24
+ if [ -x "$HOOKS_DIR/commit-msg-precommit" ]; then
25
+ exec "$HOOKS_DIR/commit-msg-precommit" "$@"
42
26
  fi
27
+ exit 0
28
+ """
29
+
30
+
31
+ def _render_post_commit_hook(python_executable: str) -> str:
32
+ """Capture immutable commit details before starting the background publisher."""
33
+ return f"""#!/bin/sh
34
+ REPO_PATH=$(git rev-parse --show-toplevel) || exit 0
35
+ REPO_NAME=$(basename "$REPO_PATH")
36
+ COMMIT_SHA=$(git rev-parse --verify HEAD) || exit 0
37
+ COMMIT_MSG=$(git log -1 --format=%s "$COMMIT_SHA") || exit 0
38
+ mkdir -p "$HOME/.captains-log" || exit 0
39
+ # Diagnostics include the updater's exit status; publication never blocks Git.
40
+ GIT_HOOK=1 {shlex.quote(python_executable)} -m src.update_log "$REPO_NAME" "$REPO_PATH" "$COMMIT_SHA" "$COMMIT_MSG" >> "$HOME/.captains-log/update-log.log" 2>&1 < /dev/null &
41
+ exit 0
43
42
  """
44
43
 
45
44
 
@@ -72,8 +71,8 @@ def setup():
72
71
  print(" pip install git-captains-log")
73
72
  sys.exit(1)
74
73
 
75
- # Install commit-msg hook
76
- print("Installing Git commit-msg hook...")
74
+ # Migrate the legacy logger and install its post-commit replacement together.
75
+ print("Installing Git commit-msg dispatcher and post-commit logger...")
77
76
 
78
77
  # Hook templates ship inside the package (src/hooks/), so this works the
79
78
  # same way for an editable/source install and a real wheel install.
@@ -90,7 +89,13 @@ def setup():
90
89
  hook_content = _render_commit_msg_hook(sys.executable)
91
90
  commit_msg_dest.write_text(hook_content, encoding="utf-8")
92
91
  commit_msg_dest.chmod(0o755)
93
- print(f" ✓ Installed commit-msg hook to {commit_msg_dest}")
92
+ post_commit_dest = git_hooks_dir / "post-commit"
93
+ post_commit_dest.write_text(
94
+ _render_post_commit_hook(sys.executable), encoding="utf-8"
95
+ )
96
+ post_commit_dest.chmod(0o755)
97
+ print(f" ✓ Updated commit-msg dispatcher at {commit_msg_dest}")
98
+ print(f" ✓ Installed post-commit logger to {post_commit_dest}")
94
99
  else:
95
100
  print(" ⚠ Warning: commit-msg hook not found in package")
96
101
  print(" You may need to copy it manually from the repository")
@@ -195,36 +200,7 @@ fi
195
200
  exit 0
196
201
  """
197
202
 
198
- python_executable = sys.executable.replace('"', '\\"')
199
-
200
- captains_log = f"""#!/usr/bin/env sh
201
-
202
- # Captain's Log commit-msg dispatcher
203
- # Behavior:
204
- # - If commit-msg-precommit exists: run pre-commit first
205
- # - Always run Captain's Log hook afterwards using the current interpreter
206
-
207
- set -eu
208
-
209
- HOOKS_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
210
- PRECOMMIT_WRAPPER="$HOOKS_DIR/commit-msg-precommit"
211
-
212
- # 1) Run pre-commit wrapper if present
213
- if [ -x "$PRECOMMIT_WRAPPER" ]; then
214
- "$PRECOMMIT_WRAPPER" "$@"
215
- fi
216
-
217
- # 2) Run Captain's Log update via installed package
218
- REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
219
- REPO_PATH=$(git rev-parse --show-toplevel)
220
- COMMIT_MSG=$(head -n1 "$1")
221
- COMMIT_SHA=$(git rev-parse --verify HEAD 2>/dev/null || echo "no-sha")
222
-
223
- export GIT_HOOK=1
224
- exec "{python_executable}" -m src.update_log "$REPO_NAME" "$REPO_PATH" "$COMMIT_SHA" "$COMMIT_MSG"
225
-
226
- exit 0
227
- """
203
+ captains_log = _render_commit_msg_hook(sys.executable)
228
204
 
229
205
  pre_push = """#!/usr/bin/env sh
230
206
 
@@ -239,6 +215,7 @@ exit 0
239
215
  write_hook("pre-commit", pre_commit)
240
216
  write_hook("commit-msg-precommit", commit_msg_precommit)
241
217
  write_hook("commit-msg", captains_log)
218
+ write_hook("post-commit", _render_post_commit_hook(sys.executable))
242
219
  write_hook("pre-push", pre_push)
243
220
 
244
221
  # Set global git hooks path
@@ -268,9 +245,7 @@ exit 0
268
245
 
269
246
  print("\nDone. pre-commit wrappers are installed globally.")
270
247
  print("- They run pre-commit in repos with .pre-commit-config.yaml")
271
- print(
272
- "- The commit-msg wrapper will also run Captain's Log if installed at ~/.captains-log"
273
- )
248
+ print("- Captain's Log publishes asynchronously from post-commit")
274
249
 
275
250
 
276
251
  def main():
@@ -1,7 +1,10 @@
1
1
  """Git operations for Captain's Log."""
2
2
 
3
3
  import os
4
+ import shutil
5
+ import stat
4
6
  import subprocess
7
+ import time
5
8
  from pathlib import Path
6
9
 
7
10
  # Git points these at the invoking repository (and, from a worktree, at its
@@ -23,6 +26,9 @@ class GitOperations:
23
26
 
24
27
  # How many times to pull-and-retry a rejected push before giving up.
25
28
  MAX_PUSH_RETRIES = 3
29
+ LOCK_RETRIES = 5
30
+ LOCK_RETRY_DELAY = 0.2
31
+ STALE_LOCK_SECONDS = 24 * 60 * 60
26
32
 
27
33
  # Git attribute that makes concurrent same-section appends to the log's
28
34
  # markdown files merge cleanly (keep every unique line from both sides)
@@ -37,6 +43,84 @@ class GitOperations:
37
43
  repo_path: Path to the git repository
38
44
  """
39
45
  self.repo_path = repo_path
46
+ self.stage = "lock"
47
+ self.status_failed = False
48
+
49
+ def _git_directories(self):
50
+ """Resolve ordinary and linked-worktree metadata without inherited env."""
51
+ git_dir = self.repo_path / ".git"
52
+ if git_dir.is_file():
53
+ value = git_dir.read_text(encoding="utf-8").strip()
54
+ if not value.startswith("gitdir: "):
55
+ raise ValueError(f"Unrecognized Git directory: {git_dir}")
56
+ git_dir = (self.repo_path / value[8:]).resolve()
57
+ directories = [git_dir]
58
+ common = git_dir / "commondir"
59
+ if common.is_file():
60
+ directories.append((git_dir / common.read_text().strip()).resolve())
61
+ return directories
62
+
63
+ def _lock_files(self):
64
+ return [
65
+ path
66
+ for directory in self._git_directories()
67
+ for path in directory.glob("*.lock")
68
+ ]
69
+
70
+ def _recover_abandoned_lock(self, path):
71
+ """Fail closed unless an old, unchanged regular lock has no open owner.
72
+
73
+ lsof works on macOS and Linux. Missing tools, unsupported platforms,
74
+ inspection errors, symlinks, and changing files all require manual action.
75
+ """
76
+ try:
77
+ before = path.lstat()
78
+ if (
79
+ not stat.S_ISREG(before.st_mode)
80
+ or time.time() - max(before.st_mtime, before.st_ctime)
81
+ < self.STALE_LOCK_SECONDS
82
+ ):
83
+ return False
84
+ lsof = shutil.which("lsof") if os.name == "posix" else None
85
+ if lsof is None:
86
+ return False
87
+ result = subprocess.run(
88
+ [lsof, "-t", str(path.resolve())],
89
+ capture_output=True,
90
+ text=True,
91
+ timeout=2,
92
+ )
93
+ # lsof's documented no-match result: status 1, no output or errors.
94
+ if result.returncode != 1 or result.stdout.strip() or result.stderr.strip():
95
+ return False
96
+ if path.lstat() != before:
97
+ return False
98
+ path.unlink()
99
+ print(
100
+ f"Recovered abandoned Git lock: {path}; publishing pending local entries"
101
+ )
102
+ return True
103
+ except FileNotFoundError:
104
+ return True # The owning Git process removed it while we waited.
105
+ except (OSError, subprocess.TimeoutExpired):
106
+ return False
107
+
108
+ def wait_for_locks(self):
109
+ """Briefly wait for active Git operations, then recover only proven stale locks."""
110
+ self.stage = "lock"
111
+ for attempt in range(self.LOCK_RETRIES + 1):
112
+ locks = self._lock_files()
113
+ if not locks:
114
+ return True
115
+ if attempt < self.LOCK_RETRIES:
116
+ time.sleep(self.LOCK_RETRY_DELAY)
117
+ for path in locks:
118
+ self._recover_abandoned_lock(path)
119
+ remaining = self._lock_files()
120
+ if remaining:
121
+ print(f"Warning: Git locks remain; publication blocked: {remaining}")
122
+ return False
123
+ return True
40
124
 
41
125
  def _run_git(self, args: list, **kwargs) -> subprocess.CompletedProcess:
42
126
  """Run a git command scoped to repo_path, immune to inherited repo env vars.
@@ -57,6 +141,7 @@ class GitOperations:
57
141
  Returns:
58
142
  True if there are changes, False otherwise
59
143
  """
144
+ self.status_failed = False
60
145
  try:
61
146
  result = self._run_git(
62
147
  ["-C", str(self.repo_path), "status", "--porcelain"],
@@ -66,6 +151,7 @@ class GitOperations:
66
151
  )
67
152
  return bool(result.stdout.strip())
68
153
  except subprocess.CalledProcessError:
154
+ self.status_failed = True
69
155
  return False
70
156
 
71
157
  def has_lock_files(self) -> bool:
@@ -74,12 +160,7 @@ class GitOperations:
74
160
  Returns:
75
161
  True if lock files exist, False otherwise
76
162
  """
77
- git_dir = self.repo_path / ".git"
78
- if not git_dir.exists():
79
- return False
80
-
81
- lock_files = list(git_dir.glob("*.lock"))
82
- return len(lock_files) > 0
163
+ return bool(self._lock_files())
83
164
 
84
165
  def add_file(self, file_path: Path) -> bool:
85
166
  """Add a file to the git staging area.
@@ -369,30 +450,34 @@ class GitOperations:
369
450
  """
370
451
  try:
371
452
  # Check for lock files
372
- if self.has_lock_files():
373
- print("Warning: Git lock files found, skipping operations")
453
+ if not self.wait_for_locks():
374
454
  return False
375
455
 
456
+ self.stage = "add"
376
457
  self.ensure_merge_driver()
377
458
 
378
459
  # Check if there are any changes to commit
379
- if not self.has_changes():
380
- print("No changes to commit, skipping git operations")
381
- return True
382
-
383
- # Add all changes
384
- if not self.add_all():
385
- print("Warning: Failed to add files to git")
386
- return False
387
-
388
- # Commit
389
- if not self.commit(commit_message):
390
- print("Warning: Failed to commit changes")
460
+ self.stage = "status"
461
+ has_changes = self.has_changes()
462
+ if self.status_failed:
463
+ print("Warning: Failed to inspect Git status")
391
464
  return False
465
+ if has_changes:
466
+ # Include entries left by earlier failed runs in the same commit.
467
+ self.stage = "add"
468
+ if not self.add_all():
469
+ print("Warning: Failed to add files to git")
470
+ return False
471
+ self.stage = "commit"
472
+ if not self.commit(commit_message):
473
+ print("Warning: Failed to commit changes")
474
+ return False
392
475
 
393
476
  # Push, retrying with a pull in between if the remote moved on
477
+ # A clean worktree can still have commits from an earlier failed push.
478
+ self.stage = "push"
394
479
  if self.push():
395
- print("Successfully committed and pushed log updates")
480
+ print("Successfully published log updates")
396
481
  return True
397
482
 
398
483
  for attempt in range(1, self.MAX_PUSH_RETRIES + 1):
@@ -400,9 +485,12 @@ class GitOperations:
400
485
  f"Warning: Push rejected, pulling and retrying "
401
486
  f"({attempt}/{self.MAX_PUSH_RETRIES})..."
402
487
  )
403
- self.pull()
488
+ self.stage = "pull"
489
+ if self.pull() is False:
490
+ return False
491
+ self.stage = "push"
404
492
  if self.push():
405
- print("Successfully committed and pushed log updates")
493
+ print("Successfully published log updates")
406
494
  return True
407
495
 
408
496
  print("Warning: Failed to push changes")
@@ -0,0 +1,8 @@
1
+ #!/bin/sh
2
+ # Compatibility dispatcher. Install post-commit alongside this hook for logging.
3
+ # Run captains-log setup after upgrading to replace the legacy commit-msg logger.
4
+ HOOKS_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
+ if [ -x "$HOOKS_DIR/commit-msg-precommit" ]; then
6
+ exec "$HOOKS_DIR/commit-msg-precommit" "$@"
7
+ fi
8
+ exit 0
@@ -0,0 +1,8 @@
1
+ #!/bin/sh
2
+ # Compatibility dispatcher. Install post-commit alongside this hook for logging.
3
+ # Run captains-log setup after upgrading to replace the legacy commit-msg logger.
4
+ HOOKS_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
+ if [ -x "$HOOKS_DIR/commit-msg-precommit" ]; then
6
+ exec "$HOOKS_DIR/commit-msg-precommit" "$@"
7
+ fi
8
+ exit 0
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ REPO_PATH=$(git rev-parse --show-toplevel) || exit 0
3
+ REPO_NAME=$(basename "$REPO_PATH")
4
+ COMMIT_SHA=$(git rev-parse --verify HEAD) || exit 0
5
+ COMMIT_MSG=$(git log -1 --format=%s "$COMMIT_SHA") || exit 0
6
+ mkdir -p "$HOME/.captains-log" || exit 0
7
+ export PYTHONPATH="$HOME/.captains-log:$PYTHONPATH"
8
+ # Diagnostics include the updater's exit status; publication never blocks Git.
9
+ GIT_HOOK=1 python3 -m src.update_log "$REPO_NAME" "$REPO_PATH" "$COMMIT_SHA" "$COMMIT_MSG" >> "$HOME/.captains-log/update-log.log" 2>&1 < /dev/null &
10
+ exit 0
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ REPO_PATH=$(git rev-parse --show-toplevel) || exit 0
3
+ REPO_NAME=$(basename "$REPO_PATH")
4
+ COMMIT_SHA=$(git rev-parse --verify HEAD) || exit 0
5
+ COMMIT_MSG=$(git log -1 --format=%s "$COMMIT_SHA") || exit 0
6
+ mkdir -p "$HOME/.captains-log" || exit 0
7
+ # Diagnostics include the updater's exit status; publication never blocks Git.
8
+ GIT_HOOK=1 python3 -m src.update_log "$REPO_NAME" "$REPO_PATH" "$COMMIT_SHA" "$COMMIT_MSG" >> "$HOME/.captains-log/update-log.log" 2>&1 < /dev/null &
9
+ exit 0
@@ -1,12 +1,35 @@
1
1
  """Project discovery functionality for Captain's Log."""
2
2
 
3
3
  import configparser
4
+ import re
4
5
  from pathlib import Path
5
6
  from typing import Optional
6
7
 
7
8
  from src.config.config_models import Config, ProjectConfig
8
9
  from src.projects.project_models import ProjectInfo
9
10
 
11
+ _REMOTE_URL = re.compile(
12
+ r"(?:https?://|ssh://git@)(?P<host>[A-Za-z0-9.-]+)/"
13
+ r"(?P<path>[^/\s:?#@]+/[^/\s:?#@]+?)(?:\.git)?(?P<slash>/?)"
14
+ )
15
+ _SCP_LIKE_REMOTE = re.compile(
16
+ r"git@(?P<host>[A-Za-z0-9.-]+):"
17
+ r"(?P<path>[^/\s:?#@]+/[^/\s:?#@]+?)(?:\.git)?"
18
+ )
19
+
20
+
21
+ def normalize_remote_url(url: str) -> tuple[str, str]:
22
+ """Canonicalize approved remote forms, retaining raw text for all others.
23
+
24
+ Only credential-free HTTP(S), git-user SSH without ports, and git-user
25
+ SCP forms are canonicalized. Host case is ignored; path case is preserved.
26
+ Only HTTP(S) forms accept a trailing slash.
27
+ """
28
+ match = _REMOTE_URL.fullmatch(url) or _SCP_LIKE_REMOTE.fullmatch(url)
29
+ if match is None or (url.startswith("ssh://") and match.group("slash")):
30
+ return "raw", url
31
+ return "canonical", f"{match.group('host').lower()}/{match.group('path')}"
32
+
10
33
 
11
34
  class ProjectFinder:
12
35
  """Handles finding and identifying projects from repository paths."""
@@ -87,7 +110,7 @@ class ProjectFinder:
87
110
  if remote_a is None or remote_b is None:
88
111
  return True
89
112
 
90
- return remote_a == remote_b
113
+ return normalize_remote_url(remote_a) == normalize_remote_url(remote_b)
91
114
 
92
115
  def _git_remote_url(self, repo: Path) -> Optional[str]:
93
116
  """Read the "origin" remote URL from a repo's git config, if any.
@@ -0,0 +1,132 @@
1
+ """Correlated run diagnostics and durable records of unpublished local logs."""
2
+
3
+ import json
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ from src import cli_logging
10
+
11
+
12
+ def timestamp():
13
+ return datetime.now(timezone.utc).isoformat()
14
+
15
+
16
+ def pending_directory():
17
+ return Path.home() / ".captains-log" / "pending"
18
+
19
+
20
+ def warn_unpublished():
21
+ """Surface background failures even when this command uses another log repo."""
22
+ records = list(pending_directory().glob("*.json"))
23
+ if records:
24
+ cli_logging.warning(
25
+ f"Warning: {len(records)} unpublished Captain's Log run(s). "
26
+ f"Inspect {pending_directory()} and ~/.captains-log/update-log.log"
27
+ )
28
+
29
+
30
+ class PublicationRun:
31
+ """One run's diagnostic context and pending-state lifecycle.
32
+
33
+ Each run has its own marker. Successful publication clears only the snapshot
34
+ of older saved runs for this destination; a concurrent new run stays pending.
35
+ """
36
+
37
+ def __init__(self, repo, sha):
38
+ self.run_id = uuid4().hex
39
+ self.repo = repo
40
+ self.sha = sha
41
+ self.stage = "config"
42
+ self.git_ops = None
43
+ self.exit_status = "running"
44
+ self.stream = sys.stdout
45
+ self.buffer = ""
46
+ self.marker = pending_directory() / f"{self.run_id}.json"
47
+ self.record = {}
48
+ self.older = []
49
+
50
+ @property
51
+ def current_stage(self):
52
+ return self.git_ops.stage if self.git_ops is not None else self.stage
53
+
54
+ def write(self, value):
55
+ """Prefix every diagnostic line, including multiline Git errors."""
56
+ self.buffer += value
57
+ while "\n" in self.buffer:
58
+ line, self.buffer = self.buffer.split("\n", 1)
59
+ if line:
60
+ self.stream.write(
61
+ f"timestamp={timestamp()} run={self.run_id} "
62
+ f"repo={json.dumps(self.repo)} sha={json.dumps(self.sha)} "
63
+ f"stage={self.current_stage} exit_status={self.exit_status} {line}\n"
64
+ )
65
+ self.stream.flush()
66
+ return len(value)
67
+
68
+ def flush(self):
69
+ if self.buffer:
70
+ self.write("\n")
71
+ self.stream.flush()
72
+
73
+ def isatty(self):
74
+ return False
75
+
76
+ def start_pending(self, log_repo, log_file):
77
+ destination = str(log_repo.resolve()) if log_repo else None
78
+ for path in pending_directory().glob("*.json"):
79
+ try:
80
+ data = json.loads(path.read_text(encoding="utf-8"))
81
+ if (
82
+ isinstance(data, dict)
83
+ and data.get("log_repo") == destination
84
+ and data.get("saved_locally")
85
+ ):
86
+ self.older.append(path)
87
+ except (OSError, ValueError):
88
+ continue # Keep malformed records for inspection.
89
+ self.record = {
90
+ "run": self.run_id,
91
+ "repo": self.repo,
92
+ "sha": self.sha,
93
+ "log_repo": destination,
94
+ "log_file": str(log_file) if log_file else None,
95
+ "saved_locally": False,
96
+ }
97
+ self.update_pending()
98
+
99
+ def update_pending(self, **values):
100
+ self.record.update(
101
+ values,
102
+ timestamp=timestamp(),
103
+ stage=self.current_stage,
104
+ exit_status=self.exit_status,
105
+ )
106
+ self.marker.parent.mkdir(parents=True, exist_ok=True)
107
+ temporary = self.marker.with_suffix(".tmp")
108
+ temporary.write_text(json.dumps(self.record, indent=2) + "\n", encoding="utf-8")
109
+ temporary.replace(self.marker)
110
+
111
+ def published(self):
112
+ for path in [*self.older, self.marker]:
113
+ try:
114
+ path.unlink()
115
+ except FileNotFoundError:
116
+ pass
117
+ except OSError as exc:
118
+ print(f"Published, but could not clear pending record {path}: {exc}")
119
+
120
+
121
+ def publish_manual_entry(git_ops, log_repo, commit_message):
122
+ """Publish a manual entry; on failure keep a pending marker and return False."""
123
+ run = PublicationRun("manual", "manual")
124
+ run.start_pending(log_repo, None)
125
+ if git_ops.commit_and_push(commit_message):
126
+ run.published()
127
+ return True
128
+ run.stage = git_ops.stage
129
+ run.exit_status = 1
130
+ run.update_pending(saved_locally=True)
131
+ cli_logging.error(f"Saved locally; NOT published; pending={run.marker}")
132
+ return False
@@ -21,6 +21,7 @@ from src.config import Config, load_config
21
21
  from src.git import GitOperations
22
22
  from src.logs.log_manager import LogManager
23
23
  from src.projects import ProjectFinder, ProjectInfo
24
+ from src.publication import publish_manual_entry, warn_unpublished
24
25
 
25
26
  EMOJIS: list[str] = ["🎯", "🏆", "🚀", "⭐️", "🎉", "📌"]
26
27
 
@@ -115,6 +116,7 @@ def build_milestone_context(config: Config, project: ProjectInfo) -> MilestoneCo
115
116
 
116
117
  def add_milestone_entry(entry_text: str) -> None:
117
118
  """High-level API to add a milestone entry for the current project."""
119
+ warn_unpublished()
118
120
  config, project = resolve_project()
119
121
 
120
122
  # Pull remote changes before deciding where the entry goes, since the
@@ -137,7 +139,8 @@ def add_milestone_entry(entry_text: str) -> None:
137
139
  commit_message = (
138
140
  f"Add milestone entry to {ctx.project.name} for {ctx.log_date.year}"
139
141
  )
140
- git_ops.commit_and_push(commit_message)
142
+ if not publish_manual_entry(git_ops, log_repo_path, commit_message):
143
+ raise SystemExit(1)
141
144
 
142
145
 
143
146
  def main() -> None:
@@ -6,6 +6,7 @@ This script processes git commit information and updates daily log files.
6
6
  """
7
7
 
8
8
  import sys
9
+ from contextlib import redirect_stderr, redirect_stdout
9
10
  from datetime import date
10
11
  from pathlib import Path
11
12
 
@@ -16,6 +17,7 @@ from src.entries import EntryProcessor
16
17
  from src.git import CommitParser, GitOperations
17
18
  from src.logs import LogManager
18
19
  from src.projects import ProjectFinder
20
+ from src.publication import PublicationRun
19
21
 
20
22
 
21
23
  # Backward compatibility exports for existing tests
@@ -68,7 +70,7 @@ def save_log(file_path, repos):
68
70
  def commit_and_push(log_repo_path, commit_msg):
69
71
  """Legacy function for backward compatibility."""
70
72
  git_ops = GitOperations(log_repo_path)
71
- git_ops.commit_and_push(commit_msg)
73
+ return git_ops.commit_and_push(commit_msg)
72
74
 
73
75
 
74
76
  def get_log_repo_and_path(project, config):
@@ -101,8 +103,7 @@ def main():
101
103
  try:
102
104
  args, log_level = cli_logging.split_log_level_args(sys.argv[1:])
103
105
  except ValueError as exc:
104
- cli_logging.error(str(exc))
105
- sys.exit(0)
106
+ _argument_error(str(exc))
106
107
  cli_logging.configure_log_level(log_level)
107
108
 
108
109
  # Check for version flag
@@ -113,22 +114,39 @@ def main():
113
114
  sys.exit(0)
114
115
 
115
116
  if len(args) < 4:
116
- print(
117
+ _argument_error(
117
118
  "Usage: update_log.py [--log-level compact|verbose|debug] "
118
119
  "<repo_name> <repo_path> <commit_sha> <commit_message>"
119
120
  )
120
- return
121
121
 
122
122
  repo_name = args[0]
123
123
  repo_path = args[1]
124
124
  commit_sha = args[2]
125
125
  commit_msg = args[3]
126
126
 
127
+ run = PublicationRun(repo_name, commit_sha)
128
+ with redirect_stdout(run), redirect_stderr(run):
129
+ status = _update(repo_name, repo_path, commit_sha, commit_msg, run)
130
+ run.exit_status = status
131
+ print(f"Run finished with exit_status={status}")
132
+ run.flush()
133
+ if status:
134
+ raise SystemExit(status)
135
+
136
+
137
+ def _argument_error(message):
138
+ run = PublicationRun("unknown", "unknown")
139
+ run.stage = "arguments"
140
+ run.exit_status = 2
141
+ with redirect_stdout(run):
142
+ print(message)
143
+ raise SystemExit(2)
144
+
145
+
146
+ def _update(repo_name, repo_path, commit_sha, commit_msg, run):
147
+ saved = False
127
148
  try:
128
- # Load configuration
129
149
  config = load_config()
130
-
131
- # Check if we should skip this commit
132
150
  if CommitParser.should_skip_commit(
133
151
  commit_sha,
134
152
  repo_path,
@@ -138,66 +156,81 @@ def main():
138
156
  print("Skipping log update: No valid commit SHA")
139
157
  else:
140
158
  print("Skipping log update: Running from within log repository")
141
- return
142
-
143
- # Find project information
144
- project_finder = ProjectFinder(config)
145
- project = project_finder.find_project(repo_path)
159
+ return 0
146
160
 
161
+ run.stage = "project"
162
+ project = ProjectFinder(config).find_project(repo_path)
147
163
  if project.is_private:
148
164
  print("Skipping log update: repository is private")
149
- return
165
+ return 0
150
166
 
151
- # Get log file information
152
167
  log_manager = LogManager(config)
153
168
  log_info = log_manager.get_log_file_info(project)
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.
169
+ # A project-specific log repo also needs the recursion guard.
170
+ if (
171
+ log_info.log_repo_path
172
+ and Path(repo_path).resolve() == log_info.log_repo_path.resolve()
173
+ ):
174
+ print("Skipping log update: Running from within log repository")
175
+ return 0
176
+ git_ops = None
158
177
  if log_info.has_git_repo and log_info.log_repo_path:
159
- GitOperations(log_info.log_repo_path).pull()
160
-
161
- # Load existing log data
178
+ run.start_pending(log_info.log_repo_path, log_info.file_path)
179
+ git_ops = GitOperations(log_info.log_repo_path)
180
+ run.git_ops = git_ops
181
+ if git_ops.wait_for_locks():
182
+ git_ops.stage = "pull"
183
+ git_ops.pull()
184
+ # A failed pull must not prevent saving the source entry locally.
185
+ # commit_and_push will expose a failed publication (or recover later).
186
+ run.git_ops = None
187
+
188
+ run.stage = "read"
162
189
  log_data = log_manager.load_log(log_info)
163
-
164
- # Process the new commit entry
165
- entry_processor = EntryProcessor()
166
-
167
- # Get or create repository entries
168
- repo_entries = log_data.get_repo_entries(repo_name)
169
-
170
- # Update with new commit
171
- updated_entries = entry_processor.update_commit_entries(
172
- repo_entries, commit_sha, commit_msg
190
+ processor = EntryProcessor()
191
+ updated = processor.update_commit_entries(
192
+ log_data.get_repo_entries(repo_name),
193
+ commit_sha,
194
+ commit_msg,
173
195
  )
174
-
175
- # Update log data
176
- log_data.set_repo_entries(repo_name, updated_entries)
177
-
178
- # Save the updated log
196
+ log_data.set_repo_entries(repo_name, updated)
197
+ run.stage = "save"
179
198
  log_manager.save_log(log_info, log_data)
180
-
181
- # Commit and push all changes if we have a git repository
182
- if log_info.has_git_repo and log_info.log_repo_path:
183
- git_ops = GitOperations(log_info.log_repo_path)
184
- commit_message = (
185
- f"Update {project.name} logs for {date.today().isoformat()}"
186
- )
187
- git_ops.commit_and_push(commit_message)
188
-
189
- print(f"Updated log for {repo_name} in project {project.name}")
190
- cli_logging.debug(
191
- f"Debug details: repo={repo_name}, repo_path={repo_path}, sha={commit_sha}"
192
- )
193
-
194
- except Exception as e:
195
- print(f"Error updating log: {e}")
196
- cli_logging.debug(
197
- f"Debug context: repo={repo_name}, path={repo_path}, sha={commit_sha}"
199
+ saved = True
200
+ print(f"Saved locally for {repo_name} in project {project.name}")
201
+
202
+ if git_ops is None:
203
+ return 0
204
+
205
+ run.update_pending(saved_locally=True)
206
+ run.git_ops = git_ops
207
+ commit_message = f"Update {project.name} logs for {date.today().isoformat()}"
208
+ if not git_ops.commit_and_push(commit_message):
209
+ run.exit_status = 1
210
+ run.update_pending()
211
+ print(f"Saved locally; NOT published; pending={run.marker}")
212
+ return 1
213
+
214
+ run.exit_status = 0
215
+ run.published()
216
+ print(f"Published log for {repo_name} in project {project.name}")
217
+ return 0
218
+ except Exception as exc:
219
+ run.exit_status = 1
220
+ print(f"Error updating log: {exc}")
221
+ if not run.record:
222
+ run.record = {"run": run.run_id, "repo": repo_name, "sha": commit_sha}
223
+ if run.record:
224
+ try:
225
+ run.update_pending(saved_locally=saved, error=str(exc))
226
+ except OSError as marker_error:
227
+ print(f"Error recording pending publication: {marker_error}")
228
+ print(
229
+ "Saved locally; NOT published"
230
+ if saved
231
+ else "Log update failed before local save"
198
232
  )
199
- # Don't exit with error code to avoid breaking git operations
200
- sys.exit(0)
233
+ return 1
201
234
 
202
235
 
203
236
  if __name__ == "__main__":
@@ -19,10 +19,12 @@ from src.entries import EntryProcessor
19
19
  from src.git import GitOperations
20
20
  from src.logs import LogManager
21
21
  from src.projects import ProjectFinder
22
+ from src.publication import publish_manual_entry, warn_unpublished
22
23
 
23
24
 
24
25
  def add_what_next_entry(entry_text: str, project_name: Optional[str], use_other: bool):
25
26
  """Add an entry to the 'Whats next' section."""
27
+ warn_unpublished()
26
28
  config = load_config()
27
29
 
28
30
  project_finder = ProjectFinder(config)
@@ -104,7 +106,10 @@ def add_what_next_entry(entry_text: str, project_name: Optional[str], use_other:
104
106
  if log_info.has_git_repo and log_info.log_repo_path:
105
107
  git_ops = GitOperations(log_info.log_repo_path)
106
108
  commit_message = f"Add What Next entry to {project.name} logs for {date.today().isoformat()}"
107
- git_ops.commit_and_push(commit_message)
109
+ if not publish_manual_entry(
110
+ git_ops, log_info.log_repo_path, commit_message
111
+ ):
112
+ sys.exit(1)
108
113
 
109
114
  cli_logging.success(
110
115
  f"Added 'Whats next' entry to {project.name} "
@@ -17,6 +17,7 @@ from src.entries import EntryProcessor
17
17
  from src.git import GitOperations
18
18
  from src.logs import LogManager
19
19
  from src.projects import ProjectFinder
20
+ from src.publication import publish_manual_entry, warn_unpublished
20
21
 
21
22
 
22
23
  def add_wtf_entry(entry_text: str):
@@ -26,6 +27,7 @@ def add_wtf_entry(entry_text: str):
26
27
  entry_text: Text for the wtf entry
27
28
  """
28
29
  # Load configuration
30
+ warn_unpublished()
29
31
  config = load_config()
30
32
 
31
33
  # Find project from current working directory
@@ -67,7 +69,10 @@ def add_wtf_entry(entry_text: str):
67
69
  commit_message = (
68
70
  f"Add WTF entry to {project.name} logs for {date.today().isoformat()}"
69
71
  )
70
- git_ops.commit_and_push(commit_message)
72
+ if not publish_manual_entry(
73
+ git_ops, log_info.log_repo_path, commit_message
74
+ ):
75
+ sys.exit(1)
71
76
 
72
77
  cli_logging.success(f"Added WTF entry to {project.name} log: {entry_text}")
73
78
  else:
@@ -1,31 +0,0 @@
1
- #!/bin/bash
2
-
3
- # Exit on any error
4
- set -e
5
-
6
- # Get repository information
7
- REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
8
- REPO_PATH=$(git rev-parse --show-toplevel)
9
- COMMIT_MSG=$(head -n1 "$1")
10
-
11
- # Get commit SHA (handle both pre-commit and post-commit scenarios)
12
- COMMIT_SHA=$(git rev-parse --verify HEAD 2>/dev/null || echo "no-sha")
13
-
14
- # Path to the update script
15
- SCRIPT_PATH="$HOME/.captains-log/src/update_log.py"
16
-
17
- # Check if script exists
18
- if [ ! -f "$SCRIPT_PATH" ]; then
19
- echo "Warning: Captain's Log script not found at $SCRIPT_PATH"
20
- echo "Please run the install script or check your installation."
21
- exit 0 # Don't fail the commit
22
- fi
23
-
24
- # Run the update script
25
- export GIT_HOOK=1
26
- export PYTHONPATH="$HOME/.captains-log:$PYTHONPATH"
27
- if ! python3 "$SCRIPT_PATH" "$REPO_NAME" "$REPO_PATH" "$COMMIT_SHA" "$COMMIT_MSG"; then
28
- echo "Warning: Captain's Log update failed, but commit will continue"
29
- echo "Check the script output above for errors"
30
- exit 0 # Don't fail the commit
31
- fi
@@ -1,21 +0,0 @@
1
- #!/bin/bash
2
-
3
- # Exit on any error
4
- set -e
5
-
6
- # Get repository information
7
- REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
8
- REPO_PATH=$(git rev-parse --show-toplevel)
9
- COMMIT_MSG=$(head -n1 "$1")
10
-
11
- # Get commit SHA (handle both pre-commit and post-commit scenarios)
12
- COMMIT_SHA=$(git rev-parse --verify HEAD 2>/dev/null || echo "no-sha")
13
-
14
- # Run the update script using the installed package
15
- export GIT_HOOK=1
16
- if ! python3 -m src.update_log "$REPO_NAME" "$REPO_PATH" "$COMMIT_SHA" "$COMMIT_MSG"; then
17
- echo "Warning: Captain's Log update failed, but commit will continue"
18
- echo "Check the script output above for errors"
19
- exit 0 # Don't fail the commit
20
- fi
21
-