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.
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/PKG-INFO +31 -5
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/README.md +30 -4
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/docs/README.md +1 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/pyproject.toml +1 -1
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/_version.py +2 -2
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/btw.py +6 -1
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/cli.py +35 -60
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/git/git_operations.py +111 -23
- git_captains_log-0.7.4/src/hooks/commit-msg +8 -0
- git_captains_log-0.7.4/src/hooks/commit-msg-package +8 -0
- git_captains_log-0.7.4/src/hooks/post-commit +10 -0
- git_captains_log-0.7.4/src/hooks/post-commit-package +9 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/projects/project_finder.py +24 -1
- git_captains_log-0.7.4/src/publication.py +132 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/stone.py +4 -1
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/update_log.py +90 -57
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/wnext.py +6 -1
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/wtf.py +6 -1
- git_captains_log-0.7.2/src/hooks/commit-msg +0 -31
- git_captains_log-0.7.2/src/hooks/commit-msg-package +0 -21
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/.gitignore +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/LICENSE +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/docs/ideas/README.md +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/docs/roadmap/README.md +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/__init__.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/app.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/cli_logging.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/config/__init__.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/config/config_loader.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/config/config_models.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/__init__.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/entry_formatter.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/entry_models.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/entries/entry_processor.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/git/__init__.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/git/commit_parser.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/__init__.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_manager.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_models.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_parser.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/logs/log_writer.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/projects/__init__.py +0 -0
- {git_captains_log-0.7.2 → git_captains_log-0.7.4}/src/projects/project_models.py +0 -0
- {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.
|
|
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`
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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`
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 7,
|
|
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
|
-
|
|
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
|
-
"""
|
|
17
|
+
"""Keep commit-message validation separate from post-commit logging.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
#
|
|
76
|
-
print("Installing Git commit-msg
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|