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.
- secretside-0.1.0/.gitignore +10 -0
- secretside-0.1.0/LICENSE +21 -0
- secretside-0.1.0/PKG-INFO +113 -0
- secretside-0.1.0/README.md +85 -0
- secretside-0.1.0/pyproject.toml +67 -0
- secretside-0.1.0/src/secretside/__init__.py +1 -0
- secretside-0.1.0/src/secretside/__main__.py +4 -0
- secretside-0.1.0/src/secretside/command.py +172 -0
- secretside-0.1.0/src/secretside/commands/__init__.py +1 -0
- secretside-0.1.0/src/secretside/commands/clean.py +195 -0
- secretside-0.1.0/src/secretside/commands/init.py +104 -0
- secretside-0.1.0/src/secretside/config.py +76 -0
- secretside-0.1.0/src/secretside/shift.py +180 -0
- secretside-0.1.0/src/secretside/utils.py +189 -0
secretside-0.1.0/LICENSE
ADDED
|
@@ -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,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
|
+
)
|