devs-common 3.2.4__tar.gz → 3.3.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.
Files changed (42) hide show
  1. {devs_common-3.2.4 → devs_common-3.3.1}/PKG-INFO +1 -1
  2. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/container.py +170 -90
  3. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/config_hash.py +55 -6
  4. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/devcontainer.py +9 -3
  5. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/PKG-INFO +1 -1
  6. {devs_common-3.2.4 → devs_common-3.3.1}/pyproject.toml +1 -1
  7. {devs_common-3.2.4 → devs_common-3.3.1}/LICENSE +0 -0
  8. {devs_common-3.2.4 → devs_common-3.3.1}/README.md +0 -0
  9. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/__init__.py +0 -0
  10. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/config.py +0 -0
  11. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/__init__.py +0 -0
  12. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/project.py +0 -0
  13. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/core/workspace.py +0 -0
  14. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/devs_config.py +0 -0
  15. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/exceptions.py +0 -0
  16. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/Dockerfile +0 -0
  17. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/devcontainer.json +0 -0
  18. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/copy-ssh.sh +0 -0
  19. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/post-create-wrapper.sh +0 -0
  20. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/python-venv-integration.sh +0 -0
  21. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/screenshot-webapp.sh +0 -0
  22. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/setup-workspace.sh +0 -0
  23. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/start-tunnel.sh +0 -0
  24. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/tunnel-status.sh +0 -0
  25. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/scripts/webapp-screenshot.py +0 -0
  26. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/init-firewall.sh +0 -0
  27. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/setup-devs-env.sh +0 -0
  28. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/setup-vscode-cache.sh +0 -0
  29. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/sudo-scripts/start-services.sh +0 -0
  30. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/templates/vscode-settings-reference.md +0 -0
  31. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/__init__.py +0 -0
  32. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/console.py +0 -0
  33. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/devcontainer_template.py +0 -0
  34. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/docker_client.py +0 -0
  35. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/file_utils.py +0 -0
  36. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/git_utils.py +0 -0
  37. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common/utils/repo_cache.py +0 -0
  38. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/SOURCES.txt +0 -0
  39. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/dependency_links.txt +0 -0
  40. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/requires.txt +0 -0
  41. {devs_common-3.2.4 → devs_common-3.3.1}/devs_common.egg-info/top_level.txt +0 -0
  42. {devs_common-3.2.4 → devs_common-3.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-common
3
- Version: 3.2.4
3
+ Version: 3.3.1
4
4
  Summary: Shared utilities for devs package ecosystem
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -40,7 +40,7 @@ from ..utils.docker_client import DockerClient
40
40
  from ..utils.devcontainer import DevContainerCLI
41
41
  from ..utils.devcontainer_template import get_template_dir
42
42
  from ..utils.console import get_console
43
- from ..utils.config_hash import compute_env_config_hash, get_env_mount_path
43
+ from ..utils.config_hash import compute_env_config_hash, compute_devcontainer_hash, get_env_mount_path
44
44
  from .project import Project
45
45
 
46
46
  # Initialize console based on environment
@@ -138,100 +138,84 @@ class ContainerManager:
138
138
 
139
139
  return labels
140
140
 
141
- def should_rebuild_image(self, dev_name: str) -> Tuple[bool, str]:
142
- """Check if devcontainer image should be rebuilt.
143
-
141
+ def should_rebuild_image(self, dev_name: str, project_labels: dict) -> Tuple[bool, str]:
142
+ """Check if devcontainer image should be rebuilt based on content hash.
143
+
144
+ Compares the content hash of devcontainer files against the hash stored
145
+ in the existing container's label. This avoids false positives from fresh
146
+ checkouts, rebases, or other operations that update file timestamps without
147
+ changing the actual content.
148
+
144
149
  Args:
145
150
  dev_name: Development environment name
146
-
151
+ project_labels: Labels used to find the existing container
152
+
147
153
  Returns:
148
154
  Tuple of (should_rebuild, reason)
149
155
  """
150
156
  try:
151
- # Find existing images for this devcontainer configuration
152
- image_pattern = f"vsc-{self.project.get_workspace_name(dev_name)}-"
153
- existing_images = self.docker.find_images_by_pattern(image_pattern)
154
-
155
- if not existing_images:
156
- return False, "No existing image found"
157
-
158
- # Get the newest image creation time
159
- newest_image_time = None
160
- for image_name in existing_images:
161
- image_time = self.docker.get_image_creation_time(image_name)
162
- if image_time and (not newest_image_time or image_time > newest_image_time):
163
- newest_image_time = image_time
164
-
165
- if not newest_image_time:
166
- return False, "Could not determine image age"
167
-
168
- # Check if devcontainer-related files are newer than the image
169
- devcontainer_files = [
170
- self.project.project_dir / ".devcontainer",
171
- self.project.project_dir / "Dockerfile",
172
- self.project.project_dir / "docker-compose.yml",
173
- self.project.project_dir / "docker-compose.yaml",
174
- ]
175
-
176
- for file_path in devcontainer_files:
177
- if file_path.exists():
178
- if file_path.is_file():
179
- file_time = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc)
180
- if file_time > newest_image_time:
181
- return True, f"File newer than image: {file_path.name}"
182
- elif file_path.is_dir():
183
- # For directories, find the newest file
184
- newest_file_time = None
185
- newest_file = None
186
-
187
- for item in file_path.rglob('*'):
188
- if item.is_file():
189
- item_time = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
190
- if not newest_file_time or item_time > newest_file_time:
191
- newest_file_time = item_time
192
- newest_file = item
193
-
194
- if newest_file_time and newest_file_time > newest_image_time:
195
- return True, f"File newer than image: {newest_file}"
196
-
197
- return False, "Image is up to date"
198
-
157
+ current_hash = compute_devcontainer_hash(self.project.project_dir)
158
+
159
+ # Look for an existing container to compare against
160
+ existing_containers = self.docker.find_containers_by_labels(project_labels)
161
+ if not existing_containers:
162
+ return False, "No existing container to compare against"
163
+
164
+ existing_container = existing_containers[0]
165
+ existing_labels = existing_container.get('labels', {})
166
+ stored_hash = existing_labels.get('devs.devcontainer-hash', '')
167
+
168
+ if not stored_hash:
169
+ # No hash stored yet — don't trigger a rebuild speculatively
170
+ return False, "No devcontainer hash label on existing container"
171
+
172
+ if stored_hash != current_hash:
173
+ return True, f"Devcontainer files changed ({stored_hash} → {current_hash})"
174
+
175
+ return False, "Devcontainer files unchanged"
176
+
199
177
  except (DockerError, OSError) as e:
200
178
  console.print(f"[yellow]Warning: Could not check image rebuild status: {e}[/yellow]")
201
179
  return False, "Could not determine rebuild status"
202
180
 
203
181
  def ensure_container_running(
204
- self,
182
+ self,
205
183
  dev_name: str,
206
184
  workspace_dir: Path,
207
185
  force_rebuild: bool = False,
186
+ check_rebuild: bool = True,
208
187
  debug: bool = False,
209
188
  live: bool = False,
210
189
  extra_env: Optional[Dict[str, str]] = None
211
190
  ) -> bool:
212
191
  """Ensure a container is running for the specified dev environment.
213
-
192
+
214
193
  Args:
215
194
  dev_name: Development environment name
216
195
  workspace_dir: Workspace directory path
217
196
  force_rebuild: Force rebuild even if not needed
197
+ check_rebuild: Automatically rebuild if devcontainer files have changed.
198
+ Set to False (e.g. for the CLI) to only rebuild when force_rebuild=True.
218
199
  debug: Show debug output for devcontainer operations
219
200
  live: Whether to use live mode (mount current directory instead of copying)
220
201
  extra_env: Additional environment variables to pass to container
221
-
202
+
222
203
  Returns:
223
204
  True if container is running successfully
224
-
205
+
225
206
  Raises:
226
207
  ContainerError: If container operations fail
227
208
  """
228
209
  workspace_info = self._get_container_info(dev_name, live)
229
210
  container_name = workspace_info["container_name"]
230
211
  project_labels = self._get_project_labels(dev_name, live)
231
-
212
+
232
213
  try:
233
214
  # Check if we need to rebuild
234
- rebuild_needed, rebuild_reason = self.should_rebuild_image(dev_name)
215
+ if check_rebuild:
216
+ rebuild_needed, rebuild_reason = self.should_rebuild_image(dev_name, project_labels)
217
+ else:
218
+ rebuild_needed, rebuild_reason = False, "Auto-rebuild disabled"
235
219
  if rebuild_needed or force_rebuild:
236
220
  if force_rebuild:
237
221
  console.print(f" 🔄 Forcing image rebuild for {dev_name}...")
@@ -248,9 +232,10 @@ class ContainerManager:
248
232
  console.print(f"[dim]Removing container: {existing_container['name']}[/dim]")
249
233
  self.docker.remove_container(existing_container['name'])
250
234
 
251
- # Compute current config hash for comparison
235
+ # Compute current config hashes for comparison
252
236
  env_mount_path = get_env_mount_path(self.project.info.name)
253
237
  current_config_hash = compute_env_config_hash(self.project.info.name)
238
+ current_devcontainer_hash = compute_devcontainer_hash(self.project.project_dir)
254
239
  console.print(f" 📁 Env mount: {env_mount_path} (hash: {current_config_hash})")
255
240
 
256
241
  # Check if container is already running
@@ -267,13 +252,19 @@ class ContainerManager:
267
252
 
268
253
  console.print(f" 🔍 Found container: {existing_container['name']} (status: {existing_container['status']}, hash: {existing_config_hash or 'none'})")
269
254
 
270
- # Check if config hash has changed
255
+ # Check if config hash has changed (only when auto-rebuild checks are enabled)
271
256
  if existing_config_hash and existing_config_hash != current_config_hash:
272
- config_hash_changed = True
273
- console.print(f" 🔄 Config hash changed ({existing_config_hash} → {current_config_hash}), will restart container")
274
- elif not existing_config_hash:
275
- console.print(f" ⚠️ Container has no config hash label, will restart to add it")
276
- config_hash_changed = True
257
+ if check_rebuild:
258
+ config_hash_changed = True
259
+ console.print(f" 🔄 Config hash changed ({existing_config_hash} → {current_config_hash}), will restart container")
260
+ else:
261
+ console.print(f" ℹ️ Env config has changed since container was created — use --rebuild-if-changed to restart")
262
+
263
+ # Check devcontainer hash when not rebuilding, to inform user of changes
264
+ if not check_rebuild:
265
+ existing_devcontainer_hash = existing_labels.get('devs.devcontainer-hash', '')
266
+ if existing_devcontainer_hash and existing_devcontainer_hash != current_devcontainer_hash:
267
+ console.print(f" ℹ️ Devcontainer files have changed since image was built — use --rebuild-if-changed to rebuild")
277
268
 
278
269
  # Check if existing container matches the requested mode
279
270
  if existing_is_live != live:
@@ -335,7 +326,8 @@ class ContainerManager:
335
326
  config_path=config_path,
336
327
  live=live,
337
328
  extra_env=extra_env,
338
- config_hash=current_config_hash
329
+ config_hash=current_config_hash,
330
+ devcontainer_hash=current_devcontainer_hash
339
331
  )
340
332
 
341
333
  if not success:
@@ -628,8 +620,8 @@ class ContainerManager:
628
620
  console.print(f" ▶️ Starting stopped container: {container_id}")
629
621
  self.docker.start_container(container_id)
630
622
  else:
631
- # Ensure container is running (may create/restart as needed)
632
- if not self.ensure_container_running(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env):
623
+ # Ensure container is running (may create/restart as needed, but never auto-rebuild)
624
+ if not self.ensure_container_running(dev_name, workspace_dir, check_rebuild=False, debug=debug, live=live, extra_env=extra_env):
633
625
  raise ContainerError(f"Failed to start container for {dev_name}")
634
626
 
635
627
  return container_name, container_workspace_dir
@@ -916,6 +908,22 @@ class ContainerManager:
916
908
  console.print("[yellow]Authentication was cancelled or failed.[/yellow]")
917
909
  return False
918
910
 
911
+ def _kill_tunnel_processes(self, container_name: str) -> None:
912
+ """Kill all tunnel processes in the container.
913
+
914
+ Uses pkill to ensure detached shell wrappers and tunnel processes
915
+ are fully terminated, not just the service-level shutdown that
916
+ 'code tunnel kill' provides.
917
+ """
918
+ subprocess.run(
919
+ ['docker', 'exec', container_name, '/usr/local/bin/code', 'tunnel', 'kill'],
920
+ capture_output=True
921
+ )
922
+ subprocess.run(
923
+ ['docker', 'exec', container_name, 'pkill', '-f', 'code tunnel'],
924
+ capture_output=True
925
+ )
926
+
919
927
  def _start_and_poll_tunnel(self, container_name: str, dev_name: str, tunnel_cmd: str,
920
928
  log_file: str, vscode_cmd: str, web_url: str, debug: bool = False) -> bool:
921
929
  """Start a detached tunnel and poll the log for startup confirmation.
@@ -923,6 +931,14 @@ class ContainerManager:
923
931
  Returns:
924
932
  True if tunnel started (or timed out), False if auth is needed.
925
933
  """
934
+ # Kill any stale tunnel processes and clear the log before starting
935
+ self._kill_tunnel_processes(container_name)
936
+ subprocess.run(
937
+ ['docker', 'exec', container_name, '/bin/sh', '-c', f'> {log_file}'],
938
+ capture_output=True
939
+ )
940
+ time.sleep(1)
941
+
926
942
  subprocess.run(
927
943
  ['docker', 'exec', '-d', container_name,
928
944
  '/bin/sh', '-c', f'{tunnel_cmd} > {log_file} 2>&1'],
@@ -950,10 +966,7 @@ class ContainerManager:
950
966
  return True
951
967
 
952
968
  if 'log in' in output.lower() or 'device' in output.lower() or 'invalid' in output.lower():
953
- subprocess.run(
954
- ['docker', 'exec', container_name, '/usr/local/bin/code', 'tunnel', 'kill'],
955
- capture_output=True
956
- )
969
+ self._kill_tunnel_processes(container_name)
957
970
  return False
958
971
 
959
972
  if debug and output.strip():
@@ -1086,14 +1099,92 @@ class ContainerManager:
1086
1099
 
1087
1100
  result = subprocess.run(cmd, capture_output=True, text=True)
1088
1101
 
1102
+ # Get auth info (logged in user/provider)
1103
+ user_result = subprocess.run(
1104
+ ['docker', 'exec', container_name,
1105
+ '/usr/local/bin/code', 'tunnel', 'user', 'show'],
1106
+ capture_output=True, text=True
1107
+ )
1108
+ auth_info = user_result.stdout.strip() if user_result.returncode == 0 else None
1109
+
1089
1110
  if result.returncode == 0:
1090
- return True, result.stdout.strip()
1111
+ raw = result.stdout.strip()
1112
+ return True, self._format_tunnel_status(raw, dev_name, auth_info)
1091
1113
  else:
1092
1114
  return False, result.stderr.strip() if result.stderr else "No tunnel running"
1093
1115
 
1094
1116
  except (DockerError, subprocess.SubprocessError) as e:
1095
1117
  raise ContainerError(f"Failed to get tunnel status in {dev_name}: {e}")
1096
1118
 
1119
+ def _format_tunnel_status(self, raw_json: str, dev_name: str, auth_info: Optional[str] = None) -> str:
1120
+ """Format tunnel status JSON into human-readable output."""
1121
+ import json
1122
+ try:
1123
+ data = json.loads(raw_json)
1124
+ except json.JSONDecodeError:
1125
+ return raw_json
1126
+
1127
+ tunnel = data.get("tunnel")
1128
+ if not tunnel:
1129
+ lines = ["[dim]● No tunnel running[/dim]"]
1130
+ if auth_info:
1131
+ lines.append(f" Auth: {auth_info}")
1132
+ return "\n".join(lines)
1133
+ name = tunnel.get("name") or "unknown"
1134
+ state = tunnel.get("tunnel", "Unknown")
1135
+ started_at = tunnel.get("started_at")
1136
+ connected_at = tunnel.get("last_connected_at")
1137
+ disconnected_at = tunnel.get("last_disconnected_at")
1138
+ fail_reason = tunnel.get("last_fail_reason")
1139
+
1140
+ lines = []
1141
+
1142
+ # Status line with color
1143
+ if state == "Connected":
1144
+ lines.append(f"[bold green]● Connected[/bold green] ({name})")
1145
+ elif state == "Disconnected":
1146
+ lines.append(f"[bold red]● Disconnected[/bold red] ({name})")
1147
+ else:
1148
+ lines.append(f"[bold yellow]● {state}[/bold yellow] ({name})")
1149
+
1150
+ # Timestamps
1151
+ if started_at:
1152
+ lines.append(f" Started: {self._format_timestamp(started_at)}")
1153
+ if connected_at:
1154
+ lines.append(f" Connected: {self._format_timestamp(connected_at)}")
1155
+ if disconnected_at:
1156
+ lines.append(f" Disconnected: {self._format_timestamp(disconnected_at)}")
1157
+ if fail_reason:
1158
+ lines.append(f" Fail reason: {fail_reason}")
1159
+ if auth_info:
1160
+ lines.append(f" Auth: {auth_info}")
1161
+
1162
+ # Connection info when connected
1163
+ if state == "Connected":
1164
+ tunnel_name = self._get_tunnel_name(dev_name)
1165
+ lines.append("")
1166
+ lines.append("[bold]Connect:[/bold]")
1167
+ lines.append(f" Desktop: code --remote tunnel+{tunnel_name}")
1168
+ lines.append(f" Browser: https://vscode.dev/tunnel/{tunnel_name}")
1169
+
1170
+ return "\n".join(lines)
1171
+
1172
+ @staticmethod
1173
+ def _format_timestamp(ts: str) -> str:
1174
+ """Format an ISO timestamp into a friendly local time string."""
1175
+ from datetime import datetime
1176
+ try:
1177
+ # Handle nanosecond precision by truncating to microseconds
1178
+ if "." in ts:
1179
+ base, frac = ts.split(".")
1180
+ frac = frac.rstrip("Z")[:6]
1181
+ ts = f"{base}.{frac}Z"
1182
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
1183
+ local_dt = dt.astimezone()
1184
+ return local_dt.strftime("%H:%M:%S %Z")
1185
+ except (ValueError, TypeError):
1186
+ return ts
1187
+
1097
1188
  def kill_tunnel(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> bool:
1098
1189
  """Kill a running VS Code tunnel in the container.
1099
1190
 
@@ -1116,24 +1207,13 @@ class ContainerManager:
1116
1207
  dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env, reuse_existing=True
1117
1208
  )
1118
1209
 
1119
- # Kill the tunnel
1120
- cmd = [
1121
- 'docker', 'exec',
1122
- container_name,
1123
- '/usr/local/bin/code', 'tunnel', 'kill'
1124
- ]
1125
-
1210
+ # Kill the tunnel (both service-level and process-level)
1126
1211
  if debug:
1127
- console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
1128
-
1129
- result = subprocess.run(cmd, capture_output=True, text=True)
1212
+ console.print(f"[dim]Killing tunnel processes in {container_name}[/dim]")
1130
1213
 
1131
- if result.returncode == 0:
1132
- console.print(f" Tunnel killed in: {dev_name}")
1133
- return True
1134
- else:
1135
- console.print(f" No tunnel to kill in: {dev_name}")
1136
- return False
1214
+ self._kill_tunnel_processes(container_name)
1215
+ console.print(f" Tunnel killed in: {dev_name}")
1216
+ return True
1137
1217
 
1138
1218
  except (DockerError, subprocess.SubprocessError) as e:
1139
1219
  raise ContainerError(f"Failed to kill tunnel in {dev_name}: {e}")
@@ -39,11 +39,60 @@ def compute_env_config_hash(project_name: str) -> str:
39
39
  Short hash string (first 12 chars of SHA256)
40
40
  """
41
41
  env_path = get_env_mount_path(project_name)
42
- return _hash_directory_mtimes(env_path)
42
+ return _hash_directory_contents(env_path)
43
43
 
44
44
 
45
- def _hash_directory_mtimes(directory: Path) -> str:
46
- """Hash mtimes of all files in a directory.
45
+ def compute_devcontainer_hash(project_dir: Path) -> str:
46
+ """Compute a content hash of devcontainer-related files.
47
+
48
+ Uses file contents (not mtimes) so that fresh checkouts or rebases that
49
+ don't change the actual devcontainer files produce the same hash.
50
+
51
+ Args:
52
+ project_dir: Root directory of the project
53
+
54
+ Returns:
55
+ Short hash string (first 12 chars of SHA256)
56
+ """
57
+ hasher = hashlib.sha256()
58
+
59
+ devcontainer_paths = [
60
+ project_dir / ".devcontainer",
61
+ project_dir / "Dockerfile",
62
+ project_dir / "docker-compose.yml",
63
+ project_dir / "docker-compose.yaml",
64
+ ]
65
+
66
+ found_any = False
67
+ try:
68
+ for path in devcontainer_paths:
69
+ if not path.exists():
70
+ continue
71
+ found_any = True
72
+ if path.is_file():
73
+ rel = path.relative_to(project_dir)
74
+ hasher.update(str(rel).encode())
75
+ hasher.update(path.read_bytes())
76
+ elif path.is_dir():
77
+ for item in sorted(path.rglob("*")):
78
+ if item.is_file():
79
+ rel = item.relative_to(project_dir)
80
+ hasher.update(str(rel).encode())
81
+ hasher.update(item.read_bytes())
82
+ except (OSError, PermissionError):
83
+ hasher.update(b"error")
84
+
85
+ if not found_any:
86
+ hasher.update(b"no-devcontainer")
87
+
88
+ return hasher.hexdigest()[:12]
89
+
90
+
91
+ def _hash_directory_contents(directory: Path) -> str:
92
+ """Hash the contents of all files in a directory.
93
+
94
+ Uses file contents rather than mtimes so that fresh checkouts or rebases
95
+ that don't change actual file content produce the same hash.
47
96
 
48
97
  Args:
49
98
  directory: Directory to hash
@@ -65,10 +114,10 @@ def _hash_directory_mtimes(directory: Path) -> str:
65
114
  files = sorted(directory.rglob("*"))
66
115
  for file_path in files:
67
116
  if file_path.is_file():
68
- # Include relative path and mtime in hash
117
+ # Include relative path and file contents in hash
69
118
  rel_path = file_path.relative_to(directory)
70
- mtime = file_path.stat().st_mtime
71
- hasher.update(f"{rel_path}:{mtime}".encode())
119
+ hasher.update(str(rel_path).encode())
120
+ hasher.update(file_path.read_bytes())
72
121
  except (OSError, PermissionError):
73
122
  hasher.update(b"error")
74
123
 
@@ -238,10 +238,11 @@ class DevContainerCLI:
238
238
  config_path: Optional[Path] = None,
239
239
  live: bool = False,
240
240
  extra_env: Optional[dict] = None,
241
- config_hash: Optional[str] = None
241
+ config_hash: Optional[str] = None,
242
+ devcontainer_hash: Optional[str] = None
242
243
  ) -> bool:
243
244
  """Start a devcontainer.
244
-
245
+
245
246
  Args:
246
247
  workspace_folder: Path to workspace folder on host
247
248
  dev_name: Development environment name
@@ -254,7 +255,8 @@ class DevContainerCLI:
254
255
  config_path: Optional path to external devcontainer config directory
255
256
  live: Whether to use live mode (mount current directory)
256
257
  extra_env: Additional environment variables to pass to container
257
- config_hash: Optional hash of config files for detecting changes
258
+ config_hash: Optional hash of env config files for detecting changes
259
+ devcontainer_hash: Optional content hash of devcontainer files for rebuild detection
258
260
 
259
261
  Returns:
260
262
  True if successful
@@ -291,6 +293,10 @@ class DevContainerCLI:
291
293
  if config_hash:
292
294
  cmd.extend(['--id-label', f'devs.config-hash={config_hash}'])
293
295
 
296
+ # Add devcontainer content hash label for rebuild detection
297
+ if devcontainer_hash:
298
+ cmd.extend(['--id-label', f'devs.devcontainer-hash={devcontainer_hash}'])
299
+
294
300
  # Add extra container labels from config if provided
295
301
  if self.config and hasattr(self.config, 'container_labels'):
296
302
  for k, v in self.config.container_labels.items():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-common
3
- Version: 3.2.4
3
+ Version: 3.3.1
4
4
  Summary: Shared utilities for devs package ecosystem
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devs-common"
7
- version = "3.2.4"
7
+ version = "3.3.1"
8
8
  description = "Shared utilities for devs package ecosystem"
9
9
  authors = [
10
10
  {name = "Dan Lester"},
File without changes
File without changes
File without changes