git-hunk 0.3.1__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-hunk
3
- Version: 0.3.1
3
+ Version: 0.4.0
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
@@ -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
@@ -103,9 +104,9 @@ class CliGroup(click.Group):
103
104
  ctx.exit(130)
104
105
 
105
106
 
106
- def _require_worktree_root() -> str:
107
+ def _require_worktree_context() -> tuple[str, str]:
107
108
  try:
108
- return get_worktree_root()
109
+ return get_worktree_context()
109
110
  # Usually there is no worktree to anchor to, but rev-parse also refuses on
110
111
  # dubious ownership or a bad config value, and those say how to fix them.
111
112
  # Classifying git's English is what this avoids; passing it on is not.
@@ -243,10 +244,16 @@ def _make_targets(
243
244
 
244
245
 
245
246
  def _select_hunks(
246
- *, hunks: list[Hunk], targets: list[_Target], inventory_hunks: list[Hunk]
247
+ *,
248
+ hunks: list[Hunk],
249
+ targets: list[_Target],
250
+ inventory_hunks: list[Hunk],
251
+ invocation_prefix: str,
252
+ worktree_root: str,
247
253
  ) -> list[Hunk]:
248
254
  files = {h.file for h in hunks}
249
255
  eligible_ids = {hunk.id for hunk in hunks}
256
+ resolved_worktree_root = Path(worktree_root).resolve()
250
257
  selected: list[Hunk] = []
251
258
  seen: set[str] = set()
252
259
  for target in targets:
@@ -259,9 +266,32 @@ def _select_hunks(
259
266
  if matches[0].id not in eligible_ids:
260
267
  raise CliError(f"hunk '{target.arg}' is not eligible for this command")
261
268
  else:
269
+ operand_path = resolved_worktree_root / target.path
270
+ if (
271
+ operand_path.is_dir()
272
+ and operand_path.resolve().is_relative_to(resolved_worktree_root)
273
+ and any(
274
+ target.path == "." or path.startswith(f"{target.path}/")
275
+ for path in files
276
+ )
277
+ ):
278
+ raise CliError(
279
+ f"directory operands are not expanded: '{target.arg}'",
280
+ tip="pass exact Repository paths (a shell-expanded glob is "
281
+ "acceptable when it covers the intended tracked files)",
282
+ )
283
+ suggested_path = posixpath.normpath(
284
+ posixpath.join(invocation_prefix, target.path)
285
+ )
286
+ tip = (
287
+ "Repository paths are worktree-root-relative; "
288
+ f"did you mean '{suggested_path}'?"
289
+ if suggested_path in files
290
+ else "run 'git-hunk list' to see changed files and hunk ids"
291
+ )
262
292
  raise CliError(
263
293
  f"no changed file matches '{target.arg}'",
264
- tip="run 'git-hunk list' to see changed files and hunk ids",
294
+ tip=tip,
265
295
  )
266
296
  for hunk in matches:
267
297
  if hunk.id not in seen:
@@ -368,6 +398,7 @@ def _apply_selection(
368
398
  targets: list[_Target],
369
399
  selection: _Selection,
370
400
  worktree_root: str,
401
+ invocation_prefix: str,
371
402
  staged: bool,
372
403
  cached: bool,
373
404
  reverse: bool,
@@ -379,7 +410,11 @@ def _apply_selection(
379
410
  hunks = [hunk for hunk in inventory.hunks if hunk.status == status]
380
411
  diff_output = inventory.staged_diff if staged else inventory.unstaged_diff
381
412
  selected = _select_hunks(
382
- hunks=hunks, targets=targets, inventory_hunks=inventory.hunks
413
+ hunks=hunks,
414
+ targets=targets,
415
+ inventory_hunks=inventory.hunks,
416
+ invocation_prefix=invocation_prefix,
417
+ worktree_root=worktree_root,
383
418
  )
384
419
  selected = _apply_line_filter(hunks=selected, selection=selection, reverse=reverse)
385
420
 
@@ -473,7 +508,7 @@ def _run_patch_command(
473
508
  verb: str,
474
509
  dry_run: bool,
475
510
  ) -> None:
476
- worktree_root = _require_worktree_root()
511
+ worktree_root, invocation_prefix = _require_worktree_context()
477
512
  targets = _make_targets(
478
513
  args=args,
479
514
  worktree_root=worktree_root,
@@ -484,6 +519,7 @@ def _run_patch_command(
484
519
  targets=targets,
485
520
  selection=selection,
486
521
  worktree_root=worktree_root,
522
+ invocation_prefix=invocation_prefix,
487
523
  staged=staged,
488
524
  cached=cached,
489
525
  reverse=reverse,
@@ -571,7 +607,7 @@ def cmd_list(
571
607
  print_help(HELP_LIST)
572
608
  return
573
609
 
574
- worktree_root = _require_worktree_root()
610
+ worktree_root, _ = _require_worktree_context()
575
611
  selected_paths = {
576
612
  _make_repository_path(arg=path, worktree_root=worktree_root) for path in files
577
613
  }
@@ -612,7 +648,7 @@ def cmd_show(
612
648
  print_help(HELP_SHOW)
613
649
  return
614
650
 
615
- worktree_root = _require_worktree_root()
651
+ worktree_root, _ = _require_worktree_context()
616
652
  inventory = _get_inventory(worktree_root=worktree_root)
617
653
  hunks = _filter_inventory_hunks(
618
654
  inventory=inventory,
@@ -857,7 +893,7 @@ def cmd_commit(
857
893
  if message is None or not message.strip():
858
894
  raise CliError("commit requires a message (-m)", usage=USAGE_COMMIT)
859
895
 
860
- worktree_root = _require_worktree_root()
896
+ worktree_root, invocation_prefix = _require_worktree_context()
861
897
  commit_targets = _make_targets(
862
898
  args=list(targets),
863
899
  worktree_root=worktree_root,
@@ -883,6 +919,7 @@ def cmd_commit(
883
919
  targets=commit_targets,
884
920
  selection=selection,
885
921
  worktree_root=worktree_root,
922
+ invocation_prefix=invocation_prefix,
886
923
  staged=False,
887
924
  cached=True,
888
925
  reverse=False,
@@ -17,8 +17,8 @@ class UnsupportedChange:
17
17
 
18
18
 
19
19
  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.
20
+ # A missing root is only for the bootstrap query that discovers both
21
+ # repository coordinates. Every later call anchors there.
22
22
  # Git output and input may contain bytes that are not valid UTF-8 (e.g. a
23
23
  # Latin-1 source file). surrogateescape round-trips those bytes losslessly
24
24
  # so a rebuilt patch hands git back exactly what it emitted.
@@ -60,9 +60,14 @@ def get_diff(*, worktree_root: str, staged: bool) -> str:
60
60
  return run_git(*args, worktree_root=worktree_root)
61
61
 
62
62
 
63
- def get_worktree_root() -> str:
64
- output = run_git("rev-parse", "--show-toplevel", worktree_root=None)
65
- return output.removesuffix("\n")
63
+ def get_worktree_context() -> tuple[str, str]:
64
+ # Ask Git for this coordinate because Windows can give Git and Python
65
+ # different spellings for the same directory.
66
+ output = run_git(
67
+ "rev-parse", "--show-toplevel", "--show-prefix", worktree_root=None
68
+ )
69
+ worktree_root, invocation_prefix = output.removesuffix("\n").split("\n", 1)
70
+ return worktree_root, invocation_prefix.removesuffix("/")
66
71
 
67
72
 
68
73
  def get_untracked_files(*, worktree_root: str) -> list[str]:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes