git-hunk 0.3.1__tar.gz → 0.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-hunk
3
- Version: 0.3.1
3
+ Version: 0.4.1
4
4
  Summary: Non-interactive, programmatic git hunk staging with durable Hunk IDs.
5
5
  Project-URL: Homepage, https://github.com/wkentaro/git-hunk
6
6
  Project-URL: Issues, https://github.com/wkentaro/git-hunk/issues
@@ -71,7 +71,7 @@ variant: organize a dirty working tree into correct, focused commits, once
71
71
  following git-hunk's bundled skills and once restricted to bare Git. The
72
72
  [checked-in eval harness](https://github.com/wkentaro/git-hunk/tree/0ef14bef657e187b5ab7283b0dbd64751a02736f/eval)
73
73
  grades the exact resulting repository state — commit partition and order, final
74
- tree, index, and leftovers. This table records the qualifying run; `make eval`
74
+ tree, index, and leftovers. This table records the qualifying run; `just eval`
75
75
  reruns the protocol and prints a table in the same format.
76
76
 
77
77
  | Task | git-hunk | bare Git |
@@ -370,12 +370,16 @@ content and creates new IDs.
370
370
  Bug reports, feature requests, and pull requests are welcome on
371
371
  [GitHub](https://github.com/wkentaro/git-hunk).
372
372
 
373
+ Install [just](https://just.systems/man/en/packages.html) 1.58.0 or newer and
374
+ [uv](https://docs.astral.sh/uv/getting-started/installation/). Recipes use Bash;
375
+ on Windows, install Git for Windows and make its Bash available on `PATH`.
376
+
373
377
  ```bash
374
378
  git clone https://github.com/wkentaro/git-hunk.git
375
379
  cd git-hunk
376
- make setup # install dependencies
377
- make test # run tests
378
- make lint # run linters
380
+ just setup # install dependencies
381
+ just test # run tests
382
+ just lint # run linters
379
383
  ```
380
384
 
381
385
  ## License
@@ -43,7 +43,7 @@ variant: organize a dirty working tree into correct, focused commits, once
43
43
  following git-hunk's bundled skills and once restricted to bare Git. The
44
44
  [checked-in eval harness](https://github.com/wkentaro/git-hunk/tree/0ef14bef657e187b5ab7283b0dbd64751a02736f/eval)
45
45
  grades the exact resulting repository state — commit partition and order, final
46
- tree, index, and leftovers. This table records the qualifying run; `make eval`
46
+ tree, index, and leftovers. This table records the qualifying run; `just eval`
47
47
  reruns the protocol and prints a table in the same format.
48
48
 
49
49
  | Task | git-hunk | bare Git |
@@ -342,12 +342,16 @@ content and creates new IDs.
342
342
  Bug reports, feature requests, and pull requests are welcome on
343
343
  [GitHub](https://github.com/wkentaro/git-hunk).
344
344
 
345
+ Install [just](https://just.systems/man/en/packages.html) 1.58.0 or newer and
346
+ [uv](https://docs.astral.sh/uv/getting-started/installation/). Recipes use Bash;
347
+ on Windows, install Git for Windows and make its Bash available on `PATH`.
348
+
345
349
  ```bash
346
350
  git clone https://github.com/wkentaro/git-hunk.git
347
351
  cd git-hunk
348
- make setup # install dependencies
349
- make test # run tests
350
- make lint # run linters
352
+ just setup # install dependencies
353
+ just test # run tests
354
+ just lint # run linters
351
355
  ```
352
356
 
353
357
  ## License
@@ -6,6 +6,7 @@ import stat
6
6
  from collections.abc import Callable
7
7
  from dataclasses import dataclass
8
8
  from dataclasses import replace
9
+ from pathlib import Path
9
10
  from typing import Final
10
11
 
11
12
  import click
@@ -19,7 +20,7 @@ from ._git import get_diff
19
20
  from ._git import get_unmerged_files
20
21
  from ._git import get_unsupported_changes
21
22
  from ._git import get_untracked_files
22
- from ._git import get_worktree_root
23
+ from ._git import get_worktree_context
23
24
  from ._git import stage_files
24
25
  from ._git import unstage_added_files
25
26
  from ._git import unstage_files
@@ -68,6 +69,9 @@ JSON_SCHEMA_VERSION: Final = 2
68
69
 
69
70
 
70
71
  class CliError(Exception):
72
+ tip: str | None
73
+ usage: str | None
74
+
71
75
  def __init__(
72
76
  self,
73
77
  message: str,
@@ -103,9 +107,9 @@ class CliGroup(click.Group):
103
107
  ctx.exit(130)
104
108
 
105
109
 
106
- def _require_worktree_root() -> str:
110
+ def _require_worktree_context() -> tuple[str, str]:
107
111
  try:
108
- return get_worktree_root()
112
+ return get_worktree_context()
109
113
  # Usually there is no worktree to anchor to, but rev-parse also refuses on
110
114
  # dubious ownership or a bad config value, and those say how to fix them.
111
115
  # Classifying git's English is what this avoids; passing it on is not.
@@ -243,10 +247,16 @@ def _make_targets(
243
247
 
244
248
 
245
249
  def _select_hunks(
246
- *, hunks: list[Hunk], targets: list[_Target], inventory_hunks: list[Hunk]
250
+ *,
251
+ hunks: list[Hunk],
252
+ targets: list[_Target],
253
+ inventory_hunks: list[Hunk],
254
+ invocation_prefix: str,
255
+ worktree_root: str,
247
256
  ) -> list[Hunk]:
248
257
  files = {h.file for h in hunks}
249
258
  eligible_ids = {hunk.id for hunk in hunks}
259
+ resolved_worktree_root = Path(worktree_root).resolve()
250
260
  selected: list[Hunk] = []
251
261
  seen: set[str] = set()
252
262
  for target in targets:
@@ -259,9 +269,32 @@ def _select_hunks(
259
269
  if matches[0].id not in eligible_ids:
260
270
  raise CliError(f"hunk '{target.arg}' is not eligible for this command")
261
271
  else:
272
+ operand_path = resolved_worktree_root / target.path
273
+ if (
274
+ operand_path.is_dir()
275
+ and operand_path.resolve().is_relative_to(resolved_worktree_root)
276
+ and any(
277
+ target.path == "." or path.startswith(f"{target.path}/")
278
+ for path in files
279
+ )
280
+ ):
281
+ raise CliError(
282
+ f"directory operands are not expanded: '{target.arg}'",
283
+ tip="pass exact Repository paths (a shell-expanded glob is "
284
+ "acceptable when it covers the intended tracked files)",
285
+ )
286
+ suggested_path = posixpath.normpath(
287
+ posixpath.join(invocation_prefix, target.path)
288
+ )
289
+ tip = (
290
+ "Repository paths are worktree-root-relative; "
291
+ f"did you mean '{suggested_path}'?"
292
+ if suggested_path in files
293
+ else "run 'git-hunk list' to see changed files and hunk ids"
294
+ )
262
295
  raise CliError(
263
296
  f"no changed file matches '{target.arg}'",
264
- tip="run 'git-hunk list' to see changed files and hunk ids",
297
+ tip=tip,
265
298
  )
266
299
  for hunk in matches:
267
300
  if hunk.id not in seen:
@@ -368,6 +401,7 @@ def _apply_selection(
368
401
  targets: list[_Target],
369
402
  selection: _Selection,
370
403
  worktree_root: str,
404
+ invocation_prefix: str,
371
405
  staged: bool,
372
406
  cached: bool,
373
407
  reverse: bool,
@@ -379,7 +413,11 @@ def _apply_selection(
379
413
  hunks = [hunk for hunk in inventory.hunks if hunk.status == status]
380
414
  diff_output = inventory.staged_diff if staged else inventory.unstaged_diff
381
415
  selected = _select_hunks(
382
- hunks=hunks, targets=targets, inventory_hunks=inventory.hunks
416
+ hunks=hunks,
417
+ targets=targets,
418
+ inventory_hunks=inventory.hunks,
419
+ invocation_prefix=invocation_prefix,
420
+ worktree_root=worktree_root,
383
421
  )
384
422
  selected = _apply_line_filter(hunks=selected, selection=selection, reverse=reverse)
385
423
 
@@ -473,7 +511,7 @@ def _run_patch_command(
473
511
  verb: str,
474
512
  dry_run: bool,
475
513
  ) -> None:
476
- worktree_root = _require_worktree_root()
514
+ worktree_root, invocation_prefix = _require_worktree_context()
477
515
  targets = _make_targets(
478
516
  args=args,
479
517
  worktree_root=worktree_root,
@@ -484,6 +522,7 @@ def _run_patch_command(
484
522
  targets=targets,
485
523
  selection=selection,
486
524
  worktree_root=worktree_root,
525
+ invocation_prefix=invocation_prefix,
487
526
  staged=staged,
488
527
  cached=cached,
489
528
  reverse=reverse,
@@ -571,7 +610,7 @@ def cmd_list(
571
610
  print_help(HELP_LIST)
572
611
  return
573
612
 
574
- worktree_root = _require_worktree_root()
613
+ worktree_root, _ = _require_worktree_context()
575
614
  selected_paths = {
576
615
  _make_repository_path(arg=path, worktree_root=worktree_root) for path in files
577
616
  }
@@ -612,7 +651,7 @@ def cmd_show(
612
651
  print_help(HELP_SHOW)
613
652
  return
614
653
 
615
- worktree_root = _require_worktree_root()
654
+ worktree_root, _ = _require_worktree_context()
616
655
  inventory = _get_inventory(worktree_root=worktree_root)
617
656
  hunks = _filter_inventory_hunks(
618
657
  inventory=inventory,
@@ -832,7 +871,7 @@ def cmd_discard(
832
871
 
833
872
 
834
873
  @cli.command("commit", add_help_option=False)
835
- @click.option("-m", "message", default=None)
874
+ @click.option("-m", "messages", multiple=True)
836
875
  @click.option("-l", "line_spec", default=None)
837
876
  @click.option("--include-matching", "include_matching", multiple=True)
838
877
  @click.option("--exclude-matching", "exclude_matching", multiple=True)
@@ -843,7 +882,7 @@ def cmd_discard(
843
882
  def cmd_commit(
844
883
  *,
845
884
  targets: tuple[str, ...],
846
- message: str | None,
885
+ messages: tuple[str, ...],
847
886
  line_spec: str | None,
848
887
  include_matching: tuple[str, ...],
849
888
  exclude_matching: tuple[str, ...],
@@ -854,10 +893,13 @@ def cmd_commit(
854
893
  if show_help:
855
894
  print_help(HELP_COMMIT)
856
895
  return
857
- if message is None or not message.strip():
896
+ # Each -m is its own paragraph, as in git commit; git's own cleanup then
897
+ # collapses blank ones.
898
+ message = "\n\n".join(messages)
899
+ if not message.strip():
858
900
  raise CliError("commit requires a message (-m)", usage=USAGE_COMMIT)
859
901
 
860
- worktree_root = _require_worktree_root()
902
+ worktree_root, invocation_prefix = _require_worktree_context()
861
903
  commit_targets = _make_targets(
862
904
  args=list(targets),
863
905
  worktree_root=worktree_root,
@@ -883,6 +925,7 @@ def cmd_commit(
883
925
  targets=commit_targets,
884
926
  selection=selection,
885
927
  worktree_root=worktree_root,
928
+ invocation_prefix=invocation_prefix,
886
929
  staged=False,
887
930
  cached=True,
888
931
  reverse=False,
@@ -4,6 +4,8 @@ from typing import Final
4
4
 
5
5
 
6
6
  class GitCommandError(RuntimeError):
7
+ stderr: str
8
+
7
9
  def __init__(self, command: tuple[str, ...], stderr: str, /) -> None:
8
10
  self.stderr = stderr
9
11
  super().__init__(f"git {' '.join(command)} failed: {stderr}")
@@ -17,8 +19,8 @@ class UnsupportedChange:
17
19
 
18
20
 
19
21
  def run_git(*args: str, worktree_root: str | None, input: str | None = None) -> str:
20
- # worktree_root=None is for bootstrap only: the rev-parse that discovers
21
- # the root runs in the invocation directory. Every other call anchors there.
22
+ # A missing root is only for the bootstrap query that discovers both
23
+ # repository coordinates. Every later call anchors there.
22
24
  # Git output and input may contain bytes that are not valid UTF-8 (e.g. a
23
25
  # Latin-1 source file). surrogateescape round-trips those bytes losslessly
24
26
  # so a rebuilt patch hands git back exactly what it emitted.
@@ -60,9 +62,14 @@ def get_diff(*, worktree_root: str, staged: bool) -> str:
60
62
  return run_git(*args, worktree_root=worktree_root)
61
63
 
62
64
 
63
- def get_worktree_root() -> str:
64
- output = run_git("rev-parse", "--show-toplevel", worktree_root=None)
65
- return output.removesuffix("\n")
65
+ def get_worktree_context() -> tuple[str, str]:
66
+ # Ask Git for this coordinate because Windows can give Git and Python
67
+ # different spellings for the same directory.
68
+ output = run_git(
69
+ "rev-parse", "--show-toplevel", "--show-prefix", worktree_root=None
70
+ )
71
+ worktree_root, invocation_prefix = output.removesuffix("\n").split("\n", 1)
72
+ return worktree_root, invocation_prefix.removesuffix("/")
66
73
 
67
74
 
68
75
  def get_untracked_files(*, worktree_root: str) -> list[str]:
@@ -438,7 +438,7 @@ can retry with [bold cyan]git commit[/bold cyan].
438
438
  {USAGE_COMMIT}
439
439
 
440
440
  [bold green]Options:[/bold green]
441
- [bold cyan]-m[/bold cyan] [cyan]<msg>[/cyan] Commit message (required)
441
+ [bold cyan]-m[/bold cyan] [cyan]<msg>[/cyan] Commit message (required; repeat for more paragraphs)
442
442
  {_LINE_OPT_ROW}
443
443
 
444
444
  {_format_examples(rows=_EXAMPLES_COMMIT)}""" # noqa: E501
@@ -30,7 +30,8 @@ git-hunk list
30
30
  `git-hunk commit <id-or-path>... -m <message>` stages exactly those hunks and
31
31
  commits them; it aborts when the index already holds staged changes. A
32
32
  Repository path selects every changed hunk in one exact file and beats an ID
33
- when committing a whole file.
33
+ when committing a whole file. Repeat `-m` as with `git commit`: each one
34
+ becomes its own paragraph.
34
35
 
35
36
  Committing a complete hunk leaves every other non-`conditional` ID valid, so a
36
37
  whole plan of complete hunks chains in one call.
@@ -59,7 +59,7 @@ include = ["/README.md", "/git_hunk"]
59
59
 
60
60
  [dependency-groups]
61
61
  dev = [
62
- "gruff>=0.0.6",
62
+ "gruff>=0.0.10",
63
63
  "mdformat-frontmatter>=2.0.10",
64
64
  "mdformat>=0.7",
65
65
  "pytest-cov>=4",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes