secretside 0.1.0__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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 magicalhack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: secretside
3
+ Version: 0.1.0
4
+ Summary: Shift git commit timestamps out of work hours so your side project stays secret
5
+ Project-URL: Homepage, https://codeberg.org/magicalhack/secretside
6
+ Project-URL: Repository, https://codeberg.org/magicalhack/secretside
7
+ Project-URL: Issues, https://codeberg.org/magicalhack/secretside/issues
8
+ Author-email: magicalhack <hello@magicalhack.net>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Version Control :: Git
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: click>=8.2.1
23
+ Requires-Dist: git-filter-repo>=2.47.0
24
+ Requires-Dist: holidays>=0.80
25
+ Requires-Dist: pygit2>=1.18.2
26
+ Requires-Dist: rich>=14.0.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # secretside
30
+
31
+ Shift git commit timestamps out of work hours so your side project stays secret.
32
+
33
+ If you committed at 10:47 on a Wednesday, secretside moves it to 17:47 (or later). It also catches identity leaks — commits made with your real name or work email when you meant to use a pseudonym.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install secretside
39
+ ```
40
+
41
+ Or with [uv](https://docs.astral.sh/uv/):
42
+
43
+ ```bash
44
+ uv tool install secretside
45
+ ```
46
+
47
+ <small>Note: `libgit2` 1.9 headers are required.</small>
48
+
49
+ ## Quick start
50
+
51
+ ```bash
52
+ secretside init
53
+ secretside clean --dry-run
54
+ secretside check # quick validate for use in a git hook
55
+ ```
56
+
57
+ ## Commands
58
+
59
+ **`secretside init [repo] [--country XX] [--force]`** — Generate `secretside.toml` with jittered defaults so every project's config looks slightly different.
60
+
61
+ **`secretside clean [repo] [--dry-run]`** — Preview and apply timestamp shifts. Only touches your most recent contiguous commits. With `local_only = true` in your config (the default), only unpushed commits are considered/modified.
62
+
63
+ **`secretside inspect [repo]`** — Show commit time distribution and list all work-hour commits.
64
+
65
+ **`secretside check [repo]`** — Validate that no commits fall in work hours and no banned identities appear. Designed for CI or pre-push hooks.
66
+
67
+ ## Config
68
+
69
+ `secretside init` creates a `secretside.toml` in your repo:
70
+
71
+ ```toml
72
+ holidays = "CA"
73
+ work_days = "mon-fri"
74
+ work_hours = "08:47..17:52"
75
+ night_hours = "23:12..05:44"
76
+ local_only = true
77
+ banned = [
78
+ # ["Your Real Name", "real@email.com"],
79
+ # ["Your Real Name", "work@company.com"],
80
+ ]
81
+ pto = [
82
+ # "2025-02-07",
83
+ # "2025-12-24..2025-12-31",
84
+ ]
85
+ ```
86
+
87
+ | Key | Description |
88
+ |-----|-------------|
89
+ | `holidays` | Country code for public holidays (`"CA"`, `"US"`, `"GB"`, etc.) |
90
+ | `work_days` | Which days are work days (`"mon-fri"`, `"mon-thu"`, `"mon-wed,fri"`) |
91
+ | `work_hours` | Commits in this range on work days get shifted past `work_end` |
92
+ | `night_hours` | Shifted commits avoid landing here (organic night commits are left alone) |
93
+ | `local_only` | Only shift commits that haven't been pushed to any remote |
94
+ | `banned` | Name/email pairs to flag in `check` — keep your real identity out of the repo |
95
+ | `pto` | Dates (or `start..end` ranges) to treat as days off |
96
+
97
+ ## How it works
98
+
99
+ - Only the **contiguous tail** of your commits is touched — other people's commits are never modified
100
+ - **Weekend, holiday, and PTO commits** are left alone — already safe - unless they need shifting due to overflow from a work day
101
+ - **Organic night commits** (you actually coded at 1am) stay put
102
+ - **Chronological order** is always preserved, with original time gaps maintained where possible
103
+ - History rewriting uses [git-filter-repo](https://github.com/newren/git-filter-repo) under the hood
104
+
105
+ ## Development
106
+
107
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
108
+
109
+ ```bash
110
+ uv sync
111
+ bin/test
112
+ uv run secretside <command>
113
+ ```
@@ -0,0 +1,85 @@
1
+ # secretside
2
+
3
+ Shift git commit timestamps out of work hours so your side project stays secret.
4
+
5
+ If you committed at 10:47 on a Wednesday, secretside moves it to 17:47 (or later). It also catches identity leaks — commits made with your real name or work email when you meant to use a pseudonym.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install secretside
11
+ ```
12
+
13
+ Or with [uv](https://docs.astral.sh/uv/):
14
+
15
+ ```bash
16
+ uv tool install secretside
17
+ ```
18
+
19
+ <small>Note: `libgit2` 1.9 headers are required.</small>
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ secretside init
25
+ secretside clean --dry-run
26
+ secretside check # quick validate for use in a git hook
27
+ ```
28
+
29
+ ## Commands
30
+
31
+ **`secretside init [repo] [--country XX] [--force]`** — Generate `secretside.toml` with jittered defaults so every project's config looks slightly different.
32
+
33
+ **`secretside clean [repo] [--dry-run]`** — Preview and apply timestamp shifts. Only touches your most recent contiguous commits. With `local_only = true` in your config (the default), only unpushed commits are considered/modified.
34
+
35
+ **`secretside inspect [repo]`** — Show commit time distribution and list all work-hour commits.
36
+
37
+ **`secretside check [repo]`** — Validate that no commits fall in work hours and no banned identities appear. Designed for CI or pre-push hooks.
38
+
39
+ ## Config
40
+
41
+ `secretside init` creates a `secretside.toml` in your repo:
42
+
43
+ ```toml
44
+ holidays = "CA"
45
+ work_days = "mon-fri"
46
+ work_hours = "08:47..17:52"
47
+ night_hours = "23:12..05:44"
48
+ local_only = true
49
+ banned = [
50
+ # ["Your Real Name", "real@email.com"],
51
+ # ["Your Real Name", "work@company.com"],
52
+ ]
53
+ pto = [
54
+ # "2025-02-07",
55
+ # "2025-12-24..2025-12-31",
56
+ ]
57
+ ```
58
+
59
+ | Key | Description |
60
+ |-----|-------------|
61
+ | `holidays` | Country code for public holidays (`"CA"`, `"US"`, `"GB"`, etc.) |
62
+ | `work_days` | Which days are work days (`"mon-fri"`, `"mon-thu"`, `"mon-wed,fri"`) |
63
+ | `work_hours` | Commits in this range on work days get shifted past `work_end` |
64
+ | `night_hours` | Shifted commits avoid landing here (organic night commits are left alone) |
65
+ | `local_only` | Only shift commits that haven't been pushed to any remote |
66
+ | `banned` | Name/email pairs to flag in `check` — keep your real identity out of the repo |
67
+ | `pto` | Dates (or `start..end` ranges) to treat as days off |
68
+
69
+ ## How it works
70
+
71
+ - Only the **contiguous tail** of your commits is touched — other people's commits are never modified
72
+ - **Weekend, holiday, and PTO commits** are left alone — already safe - unless they need shifting due to overflow from a work day
73
+ - **Organic night commits** (you actually coded at 1am) stay put
74
+ - **Chronological order** is always preserved, with original time gaps maintained where possible
75
+ - History rewriting uses [git-filter-repo](https://github.com/newren/git-filter-repo) under the hood
76
+
77
+ ## Development
78
+
79
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
80
+
81
+ ```bash
82
+ uv sync
83
+ bin/test
84
+ uv run secretside <command>
85
+ ```
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "secretside"
3
+ dynamic = ["version"]
4
+ description = "Shift git commit timestamps out of work hours so your side project stays secret"
5
+ authors = [{ name = "magicalhack", email = "hello@magicalhack.net" }]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ requires-python = ">=3.11"
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Software Development :: Version Control :: Git",
20
+ ]
21
+
22
+ dependencies = [
23
+ "click>=8.2.1",
24
+ "git-filter-repo>=2.47.0",
25
+ "holidays>=0.80",
26
+ "pygit2>=1.18.2",
27
+ "rich>=14.0.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://codeberg.org/magicalhack/secretside"
32
+ Repository = "https://codeberg.org/magicalhack/secretside"
33
+ Issues = "https://codeberg.org/magicalhack/secretside/issues"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "pytest>=9.0.2",
38
+ "ruff>=0.12.1",
39
+ "ty>=0.0.16",
40
+ ]
41
+
42
+ [project.scripts]
43
+ secretside = "secretside.__main__:main"
44
+
45
+ [build-system]
46
+ requires = ["hatchling"]
47
+ build-backend = "hatchling.build"
48
+
49
+ [tool.hatch.version]
50
+ path = "src/secretside/__init__.py"
51
+
52
+ [tool.hatch.build]
53
+ include = ["src/secretside/", "LICENSE", "README.md"]
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/secretside"]
57
+
58
+ [tool.uv]
59
+ default-groups = ['dev']
60
+
61
+ [tool.ruff]
62
+ line-length = 120
63
+ target-version = "py311"
64
+ lint.extend-select = ["I"]
65
+
66
+ [tool.ruff.format]
67
+ quote-style = "double"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from secretside.command import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,172 @@
1
+ from pathlib import Path
2
+ from sys import exit
3
+
4
+ from click import argument, group
5
+ from pygit2 import Repository
6
+ from pygit2.enums import SortMode
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from secretside.commands.clean import clean
12
+ from secretside.commands.init import init as init_cmd
13
+ from secretside.config import Config, format_work_days
14
+ from secretside.shift import CommitCategory
15
+ from secretside.utils import FuzzyCommandGroup, SSCommit, configure
16
+
17
+
18
+ @group(cls=FuzzyCommandGroup, name="secretside")
19
+ def main():
20
+ """secretside - When you have a secret side project and want to keep it that way"""
21
+ pass
22
+
23
+
24
+ @main.command("inspect", short_help="Look at your repo, and report on its secretside-yness")
25
+ @configure
26
+ @argument("repo", required=False, default=".")
27
+ def inspect(config: Config, repo: str):
28
+ """Look at your repo, and report on its secretside-yness"""
29
+ console = Console()
30
+ repo_path = Path(repo).resolve()
31
+
32
+ if not (repo_path / ".git").exists():
33
+ console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
34
+ exit(1)
35
+
36
+ repository = Repository(str(repo_path))
37
+ commits = []
38
+
39
+ category_counts = {
40
+ CommitCategory.WORK: 0,
41
+ CommitCategory.NIGHT: 0,
42
+ CommitCategory.FREE_HOLIDAY: 0,
43
+ CommitCategory.FREE_WEEKEND: 0,
44
+ CommitCategory.FREE_WEEKDAY: 0,
45
+ }
46
+
47
+ for commit in repository.walk(repository.head.target, SortMode.TIME):
48
+ c = SSCommit(commit, config)
49
+ category_counts[c.category] += 1
50
+ commits.append(c)
51
+
52
+ if not commits:
53
+ console.print("[yellow]No commits found in repository[/yellow]")
54
+ return
55
+
56
+ commits.sort(key=lambda x: x.when, reverse=True)
57
+ total_commits = len(commits)
58
+
59
+ work_commits = category_counts[CommitCategory.WORK]
60
+ holiday_commits = category_counts[CommitCategory.FREE_HOLIDAY]
61
+ weekend_commits = category_counts[CommitCategory.FREE_WEEKEND]
62
+ weekday_commits = category_counts[CommitCategory.FREE_WEEKDAY]
63
+
64
+ def pct(n):
65
+ return f"{n} ({n * 100 // total_commits}%)" if total_commits else "0"
66
+
67
+ console.print()
68
+ console.print(Panel(f"Inspecting: [bold]{repo_path.name}[/bold]", expand=False))
69
+ console.print()
70
+ header = Table(box=None, show_header=False, padding=(0, 1))
71
+ header.add_column(style="bold")
72
+ header.add_column()
73
+ header.add_row(
74
+ "Work hours",
75
+ f"{config.work_start.strftime('%H:%M')}..{config.work_end.strftime('%H:%M')} {format_work_days(config.work_days)}",
76
+ )
77
+ header.add_row("Night hours", f"{config.night_start.strftime('%H:%M')}..{config.night_end.strftime('%H:%M')}")
78
+ if config.holidays_country:
79
+ header.add_row("Holidays", config.holidays_country)
80
+ header.add_row("Total commits", str(total_commits))
81
+ header.add_row("During work hours", pct(work_commits))
82
+ header.add_row("After work hours", pct(weekday_commits))
83
+ header.add_row("Weekend", pct(weekend_commits))
84
+ if holiday_commits > 0:
85
+ label = "Holiday/PTO"
86
+ if config.holidays_country:
87
+ label += f" ({config.holidays_country})"
88
+ header.add_row(label, pct(holiday_commits))
89
+ console.print(header)
90
+ console.print()
91
+
92
+ table = Table(show_header=True)
93
+ table.add_column("Hash", style="cyan")
94
+ table.add_column("Time", style="yellow")
95
+ table.add_column("Message", style="white")
96
+
97
+ day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
98
+ for commit in commits:
99
+ if commit.category != CommitCategory.WORK:
100
+ continue
101
+
102
+ time_str = commit.when.strftime("%Y-%m-%d %H:%M")
103
+ day_name = day_names[commit.when.weekday()]
104
+
105
+ table.add_row(commit.hash, f"{time_str} ({day_name})", commit.subject)
106
+
107
+ console.print(table)
108
+
109
+
110
+ @main.command("check", short_help="Check if repo passes secretside validation")
111
+ @configure
112
+ @argument("repo", required=False, default=".")
113
+ def check(config: Config, repo: str):
114
+ """Check if repository passes secretside validation (no work hour commits, no banned users)"""
115
+ console = Console()
116
+ repo_path = Path(repo).resolve()
117
+
118
+ if not (repo_path / ".git").exists():
119
+ console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
120
+ exit(1)
121
+
122
+ # Print configuration being used
123
+ console.print("[dim]Configuration:[/dim]")
124
+ console.print(
125
+ f"[dim] Work hours: {config.work_start.strftime('%H:%M')}-{config.work_end.strftime('%H:%M')} ({format_work_days(config.work_days)})[/dim]"
126
+ )
127
+ console.print(
128
+ f"[dim] Night hours: {config.night_start.strftime('%H:%M')}-{config.night_end.strftime('%H:%M')}[/dim]"
129
+ )
130
+ if config.holidays_country:
131
+ console.print(f"[dim] Holidays: {config.holidays_country}[/dim]")
132
+ if config.pto_dates:
133
+ console.print(f"[dim] PTO dates: {len(config.pto_dates)} configured[/dim]")
134
+ if config.banned:
135
+ for name, email in config.banned:
136
+ console.print(f"[dim] Banned: {name} <{email}>[/dim]")
137
+ console.print()
138
+
139
+ repository = Repository(str(repo_path))
140
+ violations = []
141
+ total_commits = 0
142
+
143
+ for commit in repository.walk(repository.head.target, SortMode.TIME):
144
+ total_commits += 1
145
+ c = SSCommit(commit, config)
146
+
147
+ # Check for work hour violations
148
+ if c.category == CommitCategory.WORK:
149
+ violations.append(f"Work hour commit: {c.hash} at {c.when.strftime('%Y-%m-%d %H:%M')} - {c.subject}")
150
+
151
+ # Check for banned identity (name or email match)
152
+ author_name = c.author.name.lower()
153
+ author_email = c.author.email.lower()
154
+ if author_name in config.banned_names:
155
+ violations.append(f"Banned name: {c.hash} by {c.author.name} <{c.author.email}> - {c.subject}")
156
+ elif author_email in config.banned_emails:
157
+ violations.append(f"Banned email: {c.hash} by {c.author.name} <{c.author.email}> - {c.subject}")
158
+
159
+ if violations:
160
+ console.print(
161
+ f"[red]✗ Repository validation failed with {len(violations)} violation(s) out of {total_commits} commits:[/red]"
162
+ )
163
+ for violation in violations:
164
+ console.print(f" [red]• {violation}[/red]")
165
+ exit(1)
166
+ else:
167
+ console.print(f"[green]✓ Repository passes secretside validation ({total_commits} commits checked)[/green]")
168
+
169
+
170
+ # Add subcommands to the main group
171
+ main.add_command(clean)
172
+ main.add_command(init_cmd)
@@ -0,0 +1 @@
1
+ # Commands module
@@ -0,0 +1,195 @@
1
+ import os
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+ from sys import exit
5
+
6
+ from click import argument, command, option
7
+ from pygit2 import Repository
8
+ from pygit2.enums import SortMode
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.prompt import Confirm
12
+ from rich.table import Table
13
+
14
+ from secretside.config import Config, format_work_days
15
+ from secretside.shift import CommitInfo, compute_shifts, find_unpushed_tail, find_user_tail
16
+ from secretside.utils import configure
17
+
18
+
19
+ @command("clean", short_help="Clean repository by shifting commits out of work hours")
20
+ @configure
21
+ @option("--dry-run", is_flag=True, default=False, help="Preview changes without rewriting")
22
+ @argument("repo", required=False, default=".")
23
+ def clean(config: Config, repo: str, dry_run: bool):
24
+ """Clean repository by shifting commits out of work hours with cascading logic"""
25
+ console = Console()
26
+ repo_path = Path(repo).resolve()
27
+
28
+ if not (repo_path / ".git").exists():
29
+ console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
30
+ exit(1)
31
+
32
+ # Walk repo → build CommitInfo list (oldest first)
33
+ repository = Repository(str(repo_path))
34
+ commit_infos = []
35
+ for git_commit in repository.walk(repository.head.target, SortMode.TIME | SortMode.REVERSE):
36
+ author_time = datetime.fromtimestamp(git_commit.author.time, tz=timezone.utc).astimezone()
37
+ commit_time = datetime.fromtimestamp(git_commit.commit_time, tz=timezone.utc).astimezone()
38
+ commit_infos.append(
39
+ CommitInfo(
40
+ id=str(git_commit.id),
41
+ timestamp=min(author_time, commit_time),
42
+ author_email=git_commit.author.email,
43
+ )
44
+ )
45
+
46
+ commit_infos.sort(key=lambda c: c.timestamp)
47
+
48
+ if not commit_infos:
49
+ console.print("[yellow]No commits found in repository[/yellow]")
50
+ return
51
+
52
+ # Only shift the contiguous tail of the current user's commits
53
+ try:
54
+ user_email = repository.config["user.email"]
55
+ except KeyError:
56
+ console.print("[red]Error: git user.email not configured[/red]")
57
+ exit(1)
58
+
59
+ others_skipped = find_user_tail(commit_infos, user_email)
60
+ my_commits = commit_infos[others_skipped:]
61
+
62
+ # If local_only, further trim to unpushed commits
63
+ pushed_skipped = 0
64
+ if config.local_only and my_commits:
65
+ pushed_oids = set()
66
+ for ref_name in repository.references:
67
+ if ref_name.startswith("refs/remotes/"):
68
+ ref = repository.references[ref_name]
69
+ for c in repository.walk(ref.peel().id, SortMode.TIME):
70
+ pushed_oids.add(str(c.id))
71
+
72
+ pushed_skipped = find_unpushed_tail(my_commits, pushed_oids)
73
+ my_commits = my_commits[pushed_skipped:]
74
+
75
+ if not my_commits:
76
+ console.print(f"[green]✓ No commits by {user_email} to shift ({len(commit_infos)} commits checked)[/green]")
77
+ return
78
+
79
+ # Compute shifts
80
+ shifted = compute_shifts(my_commits, config)
81
+
82
+ # Show preview
83
+ changed = [s for s in shifted if s.changed]
84
+ first_changed_idx = next((i for i, s in enumerate(shifted) if s.changed), len(shifted))
85
+
86
+ # Report header
87
+ console.print()
88
+ console.print(Panel(f"Cleaning: [bold]{repo_path.name}[/bold]", expand=False))
89
+ console.print()
90
+ header = Table(box=None, show_header=False, padding=(0, 1))
91
+ header.add_column(style="bold")
92
+ header.add_column()
93
+ header.add_row(
94
+ "Work hours",
95
+ f"{config.work_start.strftime('%H:%M')}..{config.work_end.strftime('%H:%M')} {format_work_days(config.work_days)}",
96
+ )
97
+ header.add_row("Night hours", f"{config.night_start.strftime('%H:%M')}..{config.night_end.strftime('%H:%M')}")
98
+ if config.holidays_country:
99
+ header.add_row("Holidays", config.holidays_country)
100
+ if config.pto_dates:
101
+ header.add_row("PTO", f"{len(config.pto_dates)} days")
102
+ if config.local_only:
103
+ header.add_row("Local only", "yes")
104
+ header.add_row("Author", user_email)
105
+ skipped_parts = [f"{others_skipped} before exclusive tail"]
106
+ if pushed_skipped:
107
+ skipped_parts.append(f"{pushed_skipped} pushed")
108
+ skipped_parts.append(f"{first_changed_idx} OK")
109
+ header.add_row("Skipped", ", ".join(skipped_parts))
110
+ header.add_row("Shifting", f"{len(changed)} of {len(shifted)}")
111
+ console.print(header)
112
+ console.print()
113
+
114
+ if not changed:
115
+ console.print("[green]✓ Nothing to shift[/green]")
116
+ return
117
+
118
+ visible = shifted[first_changed_idx:]
119
+
120
+ table = Table(show_header=True)
121
+ table.add_column("Hash", style="cyan")
122
+ table.add_column("Original")
123
+ table.add_column("New")
124
+ table.add_column("Delta")
125
+
126
+ day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
127
+ for s in visible:
128
+ orig_day = day_names[s.original_dt.weekday()]
129
+ new_day = day_names[s.new_dt.weekday()]
130
+
131
+ if s.changed:
132
+ delta = s.new_dt - s.original_dt
133
+ hours = int(delta.total_seconds() // 3600)
134
+ minutes = int((delta.total_seconds() % 3600) // 60)
135
+ delta_str = f"+{hours}h{minutes:02d}m"
136
+ orig_style = "red"
137
+ new_style = "green"
138
+ else:
139
+ delta_str = ""
140
+ orig_style = "dim"
141
+ new_style = "dim"
142
+
143
+ table.add_row(
144
+ s.id[:8],
145
+ f"[{orig_style}]{s.original_dt.strftime('%Y-%m-%d %H:%M')} ({orig_day})[/{orig_style}]",
146
+ f"[{new_style}]{s.new_dt.strftime('%Y-%m-%d %H:%M')} ({new_day})[/{new_style}]",
147
+ delta_str,
148
+ )
149
+
150
+ console.print(table)
151
+
152
+ if dry_run:
153
+ return
154
+
155
+ if not Confirm.ask(f"Rewrite {len(changed)} commits?", console=console):
156
+ console.print("[yellow]Aborted[/yellow]")
157
+ return
158
+
159
+ rewrite_history(repo_path, shifted, console)
160
+ console.print(f"[green]✓ Rewrote {len(changed)} commits[/green]")
161
+
162
+
163
+ def rewrite_history(repo_path: Path, shifted: list, console: Console):
164
+ """Use git-filter-repo to rewrite author/committer dates."""
165
+ from git_filter_repo import FilteringOptions, RepoFilter
166
+
167
+ # Build lookup: original_id (bytes) → new datetime
168
+ lookup: dict[bytes, datetime] = {}
169
+ for s in shifted:
170
+ if s.changed:
171
+ lookup[s.id.encode()] = s.new_dt
172
+
173
+ def commit_callback(commit, callback_metadata):
174
+ original_id = commit.original_id
175
+ if original_id in lookup:
176
+ new_dt = lookup[original_id]
177
+ ts = int(new_dt.timestamp())
178
+ # Preserve original timezone offset — extract from existing date
179
+ existing = commit.author_date
180
+ # author_date is b"<unix_ts> <tz_offset>"
181
+ tz_offset = existing.split(b" ")[1]
182
+ new_date = f"{ts} ".encode() + tz_offset
183
+ commit.author_date = new_date
184
+ commit.committer_date = new_date
185
+
186
+ args = FilteringOptions.parse_args(["--force", "--quiet"])
187
+
188
+ # git-filter-repo requires cwd to be the repo
189
+ original_cwd = os.getcwd()
190
+ try:
191
+ os.chdir(repo_path)
192
+ repo_filter = RepoFilter(args, commit_callback=commit_callback)
193
+ repo_filter.run()
194
+ finally:
195
+ os.chdir(original_cwd)
@@ -0,0 +1,104 @@
1
+ import locale
2
+ import random
3
+ from datetime import date, timedelta
4
+ from pathlib import Path
5
+ from sys import exit
6
+ from textwrap import dedent
7
+
8
+ from click import argument, command, option
9
+ from holidays import list_supported_countries
10
+ from rich.console import Console
11
+ from rich.prompt import Prompt
12
+
13
+
14
+ def detect_country() -> str | None:
15
+ """Try to detect country from system locale."""
16
+ try:
17
+ loc = locale.getlocale()[0] # e.g. "en_CA", "de_DE"
18
+ if loc and "_" in loc:
19
+ code = loc.split("_")[1].upper()
20
+ if code in list_supported_countries():
21
+ return code
22
+ except Exception:
23
+ pass
24
+ return None
25
+
26
+
27
+ def prompt_country(console: Console) -> str:
28
+ """Ask the user to pick a country code."""
29
+ supported = sorted(list_supported_countries().keys())
30
+ console.print(f"[dim]Supported: {', '.join(supported)}[/dim]")
31
+ while True:
32
+ code = Prompt.ask("Country code for public holidays").strip().upper()
33
+ if code in supported:
34
+ return code
35
+ console.print(f"[red]Unknown country code: {code}[/red]")
36
+
37
+
38
+ def jittered_time(base_hour: int, base_minute: int, jitter: int = 30) -> str:
39
+ """Return HH:MM with random jitter applied to the minute offset."""
40
+ total_minutes = base_hour * 60 + base_minute + random.randint(-jitter, jitter)
41
+ total_minutes = max(0, min(total_minutes, 23 * 60 + 59))
42
+ h, m = divmod(total_minutes, 60)
43
+ return f"{h:02d}:{m:02d}"
44
+
45
+
46
+ def last_friday(today: date | None = None) -> date:
47
+ """Return the most recent Friday on or before today."""
48
+ today = today or date.today()
49
+ days_since_friday = (today.weekday() - 4) % 7
50
+ return today - timedelta(days=days_since_friday)
51
+
52
+
53
+ def generate_config(country: str) -> str:
54
+ work_start = jittered_time(9, 0)
55
+ work_end = jittered_time(17, 30)
56
+ night_start = jittered_time(23, 0)
57
+ night_end = jittered_time(6, 0)
58
+ friday = last_friday()
59
+
60
+ return dedent(f"""\
61
+ holidays = "{country}"
62
+ work_days = "mon-fri"
63
+ work_hours = "{work_start}..{work_end}"
64
+ night_hours = "{night_start}..{night_end}"
65
+ local_only = true
66
+ banned = [
67
+ # ["Your Real Name", "real@email.com"],
68
+ # ["Your Real Name", "work@company.com"],
69
+ ]
70
+ pto = [
71
+ # "{friday.isoformat()}",
72
+ ]
73
+ """)
74
+
75
+
76
+ @command("init", short_help="Initialize secretside.toml in a repository")
77
+ @option("--country", default=None, help="Country code for public holidays (e.g. CA, GB, US)")
78
+ @option("--force", is_flag=True, default=False, help="Overwrite existing secretside.toml")
79
+ @argument("repo", required=False, default=".")
80
+ def init(repo: str, country: str | None, force: bool):
81
+ """Generate a secretside.toml with sensible, slightly randomized defaults."""
82
+ console = Console()
83
+ repo_path = Path(repo).resolve()
84
+
85
+ if not (repo_path / ".git").exists():
86
+ console.print(f"[red]Error: {repo_path} is not a git repository[/red]")
87
+ exit(1)
88
+
89
+ config_path = repo_path / "secretside.toml"
90
+ if config_path.exists() and not force:
91
+ console.print(f"[red]Error: {config_path} already exists (use --force to overwrite)[/red]")
92
+ exit(1)
93
+
94
+ if not country:
95
+ country = detect_country()
96
+ if country:
97
+ console.print(f"[dim]Detected country: {country}[/dim]")
98
+ else:
99
+ country = prompt_country(console)
100
+
101
+ content = generate_config(country)
102
+ config_path.write_text(content)
103
+ console.print(f"[green]Wrote {config_path}[/green]")
104
+ console.print(f"[dim]{content}[/dim]", end="")
@@ -0,0 +1,76 @@
1
+ from datetime import date, datetime, time
2
+
3
+ from holidays import country_holidays
4
+
5
+ DAY_NAMES = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
6
+ DEFAULT_WORK_DAYS = {0, 1, 2, 3, 4} # mon-fri
7
+
8
+
9
+ def parse_work_days(spec: str) -> set[int]:
10
+ """Parse a work_days spec like 'mon-thu', 'mon-wed,fri', 'mon,wed,fri' into weekday ints."""
11
+ result = set()
12
+ for part in spec.split(","):
13
+ part = part.strip().lower()
14
+ if "-" in part:
15
+ start, end = part.split("-", 1)
16
+ start_idx = DAY_NAMES.index(start.strip())
17
+ end_idx = DAY_NAMES.index(end.strip())
18
+ if end_idx < start_idx:
19
+ raise ValueError(f"Invalid range: {part}")
20
+ result.update(range(start_idx, end_idx + 1))
21
+ else:
22
+ result.add(DAY_NAMES.index(part))
23
+
24
+ return result
25
+
26
+
27
+ def format_work_days(days: set[int]) -> str:
28
+ """Format weekday ints back to a compact string like 'mon-thu' or 'mon-wed,fri'."""
29
+ if not days:
30
+ return ""
31
+ sorted_days = sorted(days)
32
+ ranges = []
33
+ start = prev = sorted_days[0]
34
+ for d in sorted_days[1:]:
35
+ if d == prev + 1:
36
+ prev = d
37
+ else:
38
+ ranges.append((start, prev))
39
+ start = prev = d
40
+ ranges.append((start, prev))
41
+ parts = []
42
+ for s, e in ranges:
43
+ if s == e:
44
+ parts.append(DAY_NAMES[s])
45
+ else:
46
+ parts.append(f"{DAY_NAMES[s]}-{DAY_NAMES[e]}")
47
+ return ",".join(parts)
48
+
49
+
50
+ class Config:
51
+ """Configuration for secretside analysis."""
52
+
53
+ def __init__(
54
+ self,
55
+ work_start: time | None = None,
56
+ work_end: time | None = None,
57
+ night_start: time | None = None,
58
+ night_end: time | None = None,
59
+ pto_dates: set[date] | None = None,
60
+ holidays_country: str | None = None,
61
+ banned: list[tuple[str, str]] | None = None,
62
+ work_days: set[int] | None = None,
63
+ local_only: bool = False,
64
+ ):
65
+ self.work_start = work_start or datetime.strptime("09:30", "%H:%M").time()
66
+ self.work_end = work_end or datetime.strptime("18:30", "%H:%M").time()
67
+ self.night_start = night_start or datetime.strptime("00:00", "%H:%M").time()
68
+ self.night_end = night_end or datetime.strptime("07:00", "%H:%M").time()
69
+ self.holidays_country = holidays_country
70
+ self.pto_dates = pto_dates or set()
71
+ self.holiday_list = country_holidays(holidays_country) if holidays_country else set()
72
+ self.banned = banned or []
73
+ self.banned_names = {name.lower() for name, _ in self.banned}
74
+ self.banned_emails = {email.lower() for _, email in self.banned}
75
+ self.work_days = work_days if work_days is not None else DEFAULT_WORK_DAYS
76
+ self.local_only = local_only
@@ -0,0 +1,180 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime, time, timedelta
3
+ from enum import Enum
4
+
5
+ from secretside.config import Config
6
+
7
+
8
+ @dataclass
9
+ class CommitInfo:
10
+ id: str
11
+ timestamp: datetime
12
+ author_email: str = ""
13
+
14
+
15
+ @dataclass
16
+ class ShiftedCommit:
17
+ id: str
18
+ original_dt: datetime
19
+ new_dt: datetime
20
+
21
+ @property
22
+ def changed(self) -> bool:
23
+ return self.original_dt != self.new_dt
24
+
25
+
26
+ class CommitCategory(Enum):
27
+ WORK = "work"
28
+ NIGHT = "night"
29
+ FREE_HOLIDAY = "free_holiday"
30
+ FREE_WEEKEND = "free_weekend"
31
+ FREE_WEEKDAY = "free_weekday"
32
+
33
+
34
+ def categorize_datetime(dt: datetime, config: Config) -> CommitCategory:
35
+ d = dt.date()
36
+ t = dt.time()
37
+ is_day_off = dt.weekday() not in config.work_days
38
+ is_holiday = d in config.holiday_list
39
+ is_pto = d in config.pto_dates
40
+
41
+ is_work_hours = config.work_start <= t < config.work_end
42
+
43
+ is_night = False
44
+ if config.night_start <= config.night_end:
45
+ is_night = config.night_start <= t <= config.night_end
46
+ else:
47
+ is_night = t >= config.night_start or t <= config.night_end
48
+
49
+ if is_day_off:
50
+ return CommitCategory.FREE_WEEKEND
51
+ if is_holiday or is_pto:
52
+ return CommitCategory.FREE_HOLIDAY
53
+ if is_work_hours:
54
+ return CommitCategory.WORK
55
+ if is_night:
56
+ return CommitCategory.NIGHT
57
+ return CommitCategory.FREE_WEEKDAY
58
+
59
+
60
+ def _is_night_time(t: time, config: Config) -> bool:
61
+ """Check if a clock time falls in night hours, independent of day type."""
62
+ if config.night_start <= config.night_end:
63
+ return config.night_start <= t <= config.night_end
64
+ else:
65
+ return t >= config.night_start or t <= config.night_end
66
+
67
+
68
+ def place_in_valid_slot(dt: datetime, original_minutes: int, config: Config, avoid_night: bool = True) -> datetime:
69
+ """Move dt out of work hours (on workdays) and optionally night hours. Loops max 14 iterations."""
70
+ for _ in range(14):
71
+ cat = categorize_datetime(dt, config)
72
+ # Night check uses the clock directly — categorize_datetime masks it on holidays/weekends
73
+ is_night = _is_night_time(dt.time(), config)
74
+
75
+ if cat == CommitCategory.WORK:
76
+ # Jump to work_end + original_minutes
77
+ base = dt.replace(hour=config.work_end.hour, minute=config.work_end.minute, second=0, microsecond=0)
78
+ dt = base + timedelta(minutes=original_minutes)
79
+ elif is_night and avoid_night:
80
+ # Shift out of night regardless of day type (holiday, weekend, etc.)
81
+ if config.night_start > config.night_end:
82
+ if dt.time() >= config.night_start:
83
+ next_day = dt.date() + timedelta(days=1)
84
+ base = datetime.combine(next_day, config.night_end, dt.tzinfo)
85
+ else:
86
+ base = dt.replace(
87
+ hour=config.night_end.hour, minute=config.night_end.minute, second=0, microsecond=0
88
+ )
89
+ else:
90
+ base = dt.replace(hour=config.night_end.hour, minute=config.night_end.minute, second=0, microsecond=0)
91
+ dt = base + timedelta(minutes=original_minutes)
92
+ else:
93
+ return dt
94
+
95
+ return dt
96
+
97
+
98
+ def find_user_tail(commits: list[CommitInfo], user_email: str) -> int:
99
+ """Return the index where the contiguous tail of user's commits begins.
100
+
101
+ Walks backwards from the end; stops at the first commit by someone else.
102
+ Returns len(commits) if no user commits are at the tail.
103
+ """
104
+ tail_start = len(commits)
105
+ for i in range(len(commits) - 1, -1, -1):
106
+ if commits[i].author_email.lower() == user_email.lower():
107
+ tail_start = i
108
+ else:
109
+ break
110
+ return tail_start
111
+
112
+
113
+ def find_unpushed_tail(commits: list[CommitInfo], pushed_oids: set[str]) -> int:
114
+ """Return the index where the contiguous tail of unpushed commits begins.
115
+
116
+ Walks backwards from the end; stops at the first pushed commit.
117
+ Returns len(commits) if all commits are pushed.
118
+ """
119
+ tail_start = len(commits)
120
+ for i in range(len(commits) - 1, -1, -1):
121
+ if commits[i].id in pushed_oids:
122
+ break
123
+ tail_start = i
124
+ return tail_start
125
+
126
+
127
+ def compute_shifts(commits: list[CommitInfo], config: Config) -> list[ShiftedCommit]:
128
+ """
129
+ Compute shifted timestamps for commits, moving work-hour and night-hour commits
130
+ to valid slots while preserving relative gaps and enforcing monotonicity.
131
+
132
+ Commits must be in chronological order (oldest first).
133
+ """
134
+ results: list[ShiftedCommit] = []
135
+ # accumulated delta per original date — the max shift applied to commits on that date
136
+ delta: dict = {} # date -> timedelta
137
+
138
+ for i, commit in enumerate(commits):
139
+ original_dt = commit.timestamp
140
+ original_date = original_dt.date()
141
+ original_minutes = original_dt.minute
142
+
143
+ # Night is only restricted for commits that weren't originally at night.
144
+ # Use clock check directly — categorize_datetime masks night on holidays/weekends.
145
+ avoid_night = not _is_night_time(original_dt.time(), config)
146
+
147
+ # 1. Apply accumulated delta for this original date
148
+ base_delta = delta.get(original_date, timedelta(0))
149
+ candidate = original_dt + base_delta
150
+
151
+ # 2. Place in valid slot
152
+ candidate = place_in_valid_slot(candidate, original_minutes, config, avoid_night)
153
+
154
+ # 3. Enforce monotonicity
155
+ if results:
156
+ prev = results[-1]
157
+ if candidate <= prev.new_dt:
158
+ prev_original_minutes = prev.original_dt.minute
159
+ # Check if this is a cross-day collision (overflow from different original date)
160
+ if prev.original_dt.date() != original_date:
161
+ # Cross-day: gap = prev original minutes + current original minutes
162
+ gap = timedelta(minutes=prev_original_minutes + original_minutes)
163
+ else:
164
+ # Same day: preserve original gap between commits
165
+ gap = original_dt - prev.original_dt
166
+ if gap <= timedelta(0):
167
+ gap = timedelta(minutes=1)
168
+
169
+ candidate = prev.new_dt + gap
170
+ # Re-place after gap adjustment
171
+ candidate = place_in_valid_slot(candidate, original_minutes, config, avoid_night)
172
+
173
+ # 4. Update accumulated delta for this original date
174
+ new_delta = candidate - original_dt
175
+ if new_delta > delta.get(original_date, timedelta(0)):
176
+ delta[original_date] = new_delta
177
+
178
+ results.append(ShiftedCommit(id=commit.id, original_dt=original_dt, new_dt=candidate))
179
+
180
+ return results
@@ -0,0 +1,189 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from functools import wraps
3
+ from pathlib import Path
4
+ from sys import exit
5
+ from tomllib import TOMLDecodeError
6
+ from tomllib import load as tomlload
7
+
8
+ from click import Group
9
+ from pygit2 import Commit
10
+ from rich.console import Console
11
+
12
+ from secretside.config import DEFAULT_WORK_DAYS, Config, parse_work_days
13
+ from secretside.shift import categorize_datetime
14
+
15
+
16
+ class SSCommit:
17
+ def __init__(
18
+ self,
19
+ commit: Commit,
20
+ config: Config,
21
+ ):
22
+ self.commit = commit
23
+ self.config = config
24
+ self.author_time = datetime.fromtimestamp(commit.author.time, tz=timezone.utc).astimezone()
25
+ self.commit_time = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).astimezone()
26
+ self.when = min(self.author_time, self.commit_time)
27
+ self.category = self.categorize()
28
+
29
+ def categorize(self):
30
+ return categorize_datetime(self.when, self.config)
31
+
32
+ @property
33
+ def hash(self):
34
+ return str(self.commit.id)[:8]
35
+
36
+ @property
37
+ def message(self):
38
+ return self.commit.message
39
+
40
+ @property
41
+ def subject(self):
42
+ return self.commit.message.split("\n")[0][:50]
43
+
44
+ @property
45
+ def author(self):
46
+ return self.commit.author
47
+
48
+
49
+ class FuzzyCommandGroup(Group):
50
+ aliases = {}
51
+
52
+ def get_command(self, ctx, cmd_name):
53
+ rv = Group.get_command(self, ctx, cmd_name)
54
+ if rv is not None:
55
+ return rv
56
+
57
+ # fuzzy matching
58
+ matches = []
59
+ for command in self.list_commands(ctx) + list(self.aliases.keys()):
60
+ if command.startswith(cmd_name.lower()):
61
+ if command in self.aliases:
62
+ matches.append(self.aliases[command])
63
+ else:
64
+ matches.append(command)
65
+
66
+ if len(matches) == 1:
67
+ return Group.get_command(self, ctx, matches[0])
68
+ elif len(matches) > 1:
69
+ ctx.fail(f"Ambiguous command: {cmd_name}. Could be: {', '.join(matches)}")
70
+ else:
71
+ ctx.fail(f"No such command: {cmd_name}")
72
+
73
+
74
+ def configure(f):
75
+ """Decorator that loads secretside.toml and injects a Config object as the first argument."""
76
+
77
+ @wraps(f)
78
+ def wrapper(*args, **kwargs):
79
+ repo_path = None
80
+ if "repo" in kwargs:
81
+ repo_path = Path(kwargs["repo"]).resolve()
82
+
83
+ config = build_config(repo_path)
84
+ return f(config, *args, **kwargs)
85
+
86
+ return wrapper
87
+
88
+
89
+ def load_config_file(repo_path: Path) -> dict:
90
+ """Load config from secretside.toml in the repo directory."""
91
+ config_path = repo_path / "secretside.toml"
92
+ if not config_path.exists():
93
+ return {}
94
+
95
+ try:
96
+ with open(config_path, "rb") as f:
97
+ return tomlload(f)
98
+ except TOMLDecodeError as e:
99
+ console = Console()
100
+ console.print(f"[yellow]Warning: Invalid TOML in {config_path}: {e}[/yellow]")
101
+ return {}
102
+ except Exception as e:
103
+ console = Console()
104
+ console.print(f"[yellow]Warning: Could not read {config_path}: {e}[/yellow]")
105
+ return {}
106
+
107
+
108
+ def build_config(repo_path: Path | None) -> Config:
109
+ """Build Config object from secretside.toml (if present) with hardcoded defaults as fallback."""
110
+ console = Console()
111
+
112
+ file_config = {}
113
+ if repo_path:
114
+ file_config = load_config_file(repo_path)
115
+
116
+ # Work hours
117
+ work_start = "09:30"
118
+ work_end = "18:30"
119
+ if file_config.get("work_hours") and ".." in file_config["work_hours"]:
120
+ work_start, work_end = file_config["work_hours"].split("..")
121
+
122
+ # Night hours
123
+ night_start = "00:00"
124
+ night_end = "07:00"
125
+ if file_config.get("night_hours") and ".." in file_config["night_hours"]:
126
+ night_start, night_end = file_config["night_hours"].split("..")
127
+
128
+ # Holidays
129
+ holidays_country = file_config.get("holidays")
130
+
131
+ # Banned identities — list of [name, email] pairs
132
+ banned = []
133
+ for entry in file_config.get("banned", []):
134
+ if isinstance(entry, (list, tuple)) and len(entry) == 2:
135
+ banned.append((entry[0], entry[1]))
136
+ else:
137
+ console.print(f"[yellow]Warning: Invalid banned entry: {entry} (expected [name, email])[/yellow]")
138
+
139
+ # local_only flag
140
+ local_only = file_config.get("local_only", False)
141
+
142
+ # Work days
143
+ work_days = DEFAULT_WORK_DAYS
144
+ if file_config.get("work_days"):
145
+ try:
146
+ work_days = parse_work_days(file_config["work_days"])
147
+ except (ValueError, IndexError):
148
+ console.print(f"[yellow]Warning: Invalid work_days in config: {file_config['work_days']}[/yellow]")
149
+
150
+ # PTO dates
151
+ pto_dates = set()
152
+ for pto_spec in file_config.get("pto", []):
153
+ try:
154
+ if ".." in pto_spec:
155
+ start_str, end_str = pto_spec.split("..")
156
+ start_date = datetime.strptime(start_str.strip(), "%Y-%m-%d").date()
157
+ end_date = datetime.strptime(end_str.strip(), "%Y-%m-%d").date()
158
+ current_date = start_date
159
+ while current_date <= end_date:
160
+ pto_dates.add(current_date)
161
+ current_date = current_date + timedelta(days=1)
162
+ else:
163
+ pto_dates.add(datetime.strptime(pto_spec, "%Y-%m-%d").date())
164
+ except ValueError:
165
+ console.print(
166
+ f"[yellow]Warning: Invalid PTO date format: {pto_spec} (use YYYY-MM-DD or YYYY-MM-DD..YYYY-MM-DD)[/yellow]"
167
+ )
168
+
169
+ # Parse time strings
170
+ try:
171
+ work_start_time = datetime.strptime(work_start, "%H:%M").time()
172
+ work_end_time = datetime.strptime(work_end, "%H:%M").time()
173
+ night_start_time = datetime.strptime(night_start, "%H:%M").time()
174
+ night_end_time = datetime.strptime(night_end, "%H:%M").time()
175
+ except ValueError:
176
+ console.print("[red]Error: Invalid time format in config. Use HH:MM format.[/red]")
177
+ exit(1)
178
+
179
+ return Config(
180
+ holidays_country=holidays_country,
181
+ pto_dates=pto_dates,
182
+ work_start=work_start_time,
183
+ work_end=work_end_time,
184
+ night_start=night_start_time,
185
+ night_end=night_end_time,
186
+ banned=banned,
187
+ work_days=work_days,
188
+ local_only=local_only,
189
+ )