feat: merge live Git config mount refresh
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Config profile API endpoints."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -33,6 +34,7 @@ from src.services.config.crud_service import (
|
||||
update_profile,
|
||||
validate_default_profiles,
|
||||
)
|
||||
from src.services.tool.instance_service import resolve_git_mounts
|
||||
from src.services.config.resolver_service import (
|
||||
resolve_default_profile,
|
||||
validate_git_url,
|
||||
@@ -174,6 +176,43 @@ async def update_config_profile(
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{profile_id}/refresh-git-mounts")
|
||||
async def refresh_profile_git_mounts(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Refresh canonical Git mount sources used by running profile instances."""
|
||||
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||
)
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
outcomes = await _running_profile_outcomes(session, profile.id)
|
||||
for outcome in outcomes:
|
||||
instance = await session.get(ToolInstance, uuid.UUID(outcome["instance_id"]))
|
||||
if (
|
||||
instance is None
|
||||
or not instance.compose_path
|
||||
or instance.selected_config_profile_id is None
|
||||
):
|
||||
continue
|
||||
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||
await resolve_git_mounts(
|
||||
session,
|
||||
resolved,
|
||||
os.path.dirname(instance.compose_path),
|
||||
)
|
||||
outcome["status"] = "refreshed"
|
||||
outcome["reason"] = "Canonical Git mount source refreshed in place"
|
||||
return {"refresh_outcomes": outcomes}
|
||||
|
||||
|
||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_profile(
|
||||
profile_id: str,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import glob as glob_module
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -158,57 +160,23 @@ def _stack_profile_mounts_with_git_mounts(
|
||||
profile_mounts: list[dict],
|
||||
git_mount_volumes: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Merge profile file mounts into overlapping git-mount sources.
|
||||
"""Leave profile and Git sources isolated.
|
||||
|
||||
When a config profile mounts static files to the same directory as a
|
||||
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
|
||||
would mask the cloned repository. Instead, copy the profile files into
|
||||
the git-mount source directory so the container sees both sets of files
|
||||
through a single bind mount.
|
||||
|
||||
Profile mounts whose target is a child of a git-mount target are copied
|
||||
into the corresponding subdirectory. Mounts that do not overlap are
|
||||
returned unchanged.
|
||||
Git mount sources are shared, read-only canonical checkouts. Profile
|
||||
content must never be copied into one because doing so dirties the
|
||||
checkout and leaks one profile's content to every instance using it.
|
||||
"""
|
||||
remaining: list[dict] = []
|
||||
for pvol in profile_mounts:
|
||||
p_source = pvol.get("source", "")
|
||||
p_target = pvol.get("target", "")
|
||||
if not p_source or not os.path.exists(p_source):
|
||||
remaining.append(pvol)
|
||||
continue
|
||||
|
||||
merged = False
|
||||
for gvol in git_mount_volumes:
|
||||
g_source = gvol.get("source", "")
|
||||
g_target = gvol.get("target", "")
|
||||
if not g_source or not os.path.isdir(g_source):
|
||||
continue
|
||||
|
||||
rel = _relative_under(g_target, p_target)
|
||||
if rel is None:
|
||||
continue
|
||||
|
||||
dst = os.path.join(g_source, rel) if rel else g_source
|
||||
if os.path.isdir(p_source):
|
||||
shutil.copytree(p_source, dst, dirs_exist_ok=True)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copy2(p_source, dst)
|
||||
|
||||
logger.debug(
|
||||
"Stacked profile mount %s into git mount %s at %s",
|
||||
p_target,
|
||||
g_target,
|
||||
dst,
|
||||
)
|
||||
merged = True
|
||||
break
|
||||
|
||||
if not merged:
|
||||
remaining.append(pvol)
|
||||
|
||||
return remaining
|
||||
for profile_mount in profile_mounts:
|
||||
profile_target = profile_mount.get("target", "")
|
||||
for git_mount in git_mount_volumes:
|
||||
if _relative_under(git_mount.get("target", ""), profile_target) is not None:
|
||||
logger.warning(
|
||||
"Config profile mount %s overlaps Git mount %s; keeping sources isolated",
|
||||
profile_target,
|
||||
git_mount.get("target"),
|
||||
)
|
||||
break
|
||||
return profile_mounts
|
||||
|
||||
|
||||
async def resolve_git_mounts(
|
||||
@@ -227,12 +195,18 @@ async def resolve_git_mounts(
|
||||
if not resolved.git_mounts:
|
||||
return []
|
||||
|
||||
# Git mount sources are profile-scoped, not instance-scoped, so compatible
|
||||
# instances bind the same canonical checkout.
|
||||
clone_parent = os.path.join(
|
||||
os.path.dirname(instance_dir or ""), "config-profiles", str(resolved.profile_id)
|
||||
)
|
||||
|
||||
# Process all git mounts concurrently
|
||||
tasks = []
|
||||
for git_mount in resolved.git_mounts:
|
||||
tasks.append(
|
||||
resolve_single_git_mount(
|
||||
session, git_mount, instance_dir, working_directory, home_dir
|
||||
session, git_mount, clone_parent, working_directory, home_dir
|
||||
)
|
||||
)
|
||||
|
||||
@@ -265,6 +239,37 @@ def normalize_git_mount(entry: dict) -> dict:
|
||||
return entry
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _git_mount_lock(clone_parent: str, remote_url: str, branch: str | None):
|
||||
"""Serialize clone and refresh operations for one canonical Git source."""
|
||||
lock_dir = os.path.join(clone_parent, "git-mounts")
|
||||
identity = f"{remote_url}:{branch or 'default'}"
|
||||
lock_path = os.path.join(
|
||||
lock_dir, f".{hashlib.sha256(identity.encode()).hexdigest()}.lock"
|
||||
)
|
||||
try:
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
with open(lock_path, "a+", encoding="utf-8") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Cannot lock Git mount source: {lock_path}") from exc
|
||||
|
||||
|
||||
def clone_git_repo_locked(
|
||||
remote_url: str,
|
||||
branch: str | None,
|
||||
clone_parent: str,
|
||||
project_name: str | None = None,
|
||||
) -> str:
|
||||
"""Clone or refresh a canonical source while holding its process lock."""
|
||||
with _git_mount_lock(clone_parent, remote_url, branch):
|
||||
return clone_git_repo(remote_url, branch, clone_parent, project_name)
|
||||
|
||||
|
||||
def clone_git_repo(
|
||||
remote_url: str,
|
||||
branch: str | None,
|
||||
@@ -425,7 +430,7 @@ def resolve_git_mount_mappings(
|
||||
async def resolve_single_git_mount(
|
||||
session: AsyncSession,
|
||||
git_mount: dict,
|
||||
instance_dir: str | None = None,
|
||||
clone_parent: str | None = None,
|
||||
working_directory: str | None = None,
|
||||
home_dir: str = "/root",
|
||||
) -> list[dict]:
|
||||
@@ -447,15 +452,15 @@ async def resolve_single_git_mount(
|
||||
logger.warning("Invalid git mount skipped: no mappings")
|
||||
return []
|
||||
|
||||
if not instance_dir:
|
||||
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
||||
if not clone_parent:
|
||||
logger.warning("Git mount skipped: no canonical profile directory provided")
|
||||
return []
|
||||
|
||||
# Clone or pull the repository. Git mounts are auxiliary, so they keep
|
||||
# using the repository URL basename rather than the project name.
|
||||
try:
|
||||
repo_path = await asyncio.to_thread(
|
||||
clone_git_repo, remote_url, branch, instance_dir
|
||||
clone_git_repo_locked, remote_url, branch, clone_parent
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -468,7 +473,12 @@ async def resolve_single_git_mount(
|
||||
return []
|
||||
|
||||
# Resolve all mappings from the cloned repo
|
||||
return resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)
|
||||
volumes = resolve_git_mount_mappings(
|
||||
repo_path, mappings, working_directory, home_dir
|
||||
)
|
||||
for volume in volumes:
|
||||
volume["readonly"] = True
|
||||
return volumes
|
||||
|
||||
|
||||
def checkout_branch(repo_path: str, branch: str) -> bool:
|
||||
@@ -1481,7 +1491,15 @@ async def start_tool_instance(
|
||||
|
||||
if ssh_keys_to_mount:
|
||||
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
|
||||
os.makedirs(ssh_dir, exist_ok=True)
|
||||
try:
|
||||
os.makedirs(ssh_dir, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to create SSH mount directory for instance %s: %s",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
ssh_keys_to_mount = []
|
||||
|
||||
key_filenames = []
|
||||
for ssh_key in ssh_keys_to_mount:
|
||||
@@ -2194,7 +2212,13 @@ async def delete_tool_instance(
|
||||
if os.path.exists(instance_dir):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(instance_dir)
|
||||
try:
|
||||
shutil.rmtree(instance_dir)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to remove instance directory %s: %s", instance_dir, exc
|
||||
)
|
||||
raise RuntimeError("Failed to remove instance files") from exc
|
||||
|
||||
await publish_lifecycle_event(
|
||||
event_bus=_event_bus,
|
||||
|
||||
@@ -80,12 +80,7 @@ class TestCloneGitRepo:
|
||||
branch = None
|
||||
clone_parent = str(tmp_path)
|
||||
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
|
||||
repo_path = (
|
||||
tmp_path
|
||||
/ "git-mounts"
|
||||
/ f"dotfiles-{url_hash}"
|
||||
/ "repo-clone"
|
||||
)
|
||||
repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
|
||||
(repo_path / ".git").mkdir(parents=True)
|
||||
|
||||
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
|
||||
@@ -99,7 +94,9 @@ class TestCloneGitRepo:
|
||||
clone.assert_not_called()
|
||||
pull.assert_called_once_with(str(repo_path), remote_url)
|
||||
|
||||
def test_replaces_incomplete_clone_before_retry(self, monkeypatch, tmp_path) -> None:
|
||||
def test_replaces_incomplete_clone_before_retry(
|
||||
self, monkeypatch, tmp_path
|
||||
) -> None:
|
||||
from src.services.tool import instance_service
|
||||
|
||||
remote_url = "https://gitlab.com/example/dotfiles"
|
||||
@@ -128,10 +125,10 @@ class TestCloneGitRepo:
|
||||
class TestStackProfileMountsWithGitMounts:
|
||||
"""Tests for _stack_profile_mounts_with_git_mounts."""
|
||||
|
||||
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None:
|
||||
"""When a profile mount targets the same directory as a git mount,
|
||||
the profile files should be copied into the git-mount source so the
|
||||
container sees both sets of files through one bind mount."""
|
||||
def test_exact_overlap_keeps_profile_files_out_of_git_source(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""Overlaps must not dirty the shared Git checkout."""
|
||||
git_source = tmp_path / "git" / "repo-clone"
|
||||
git_source.mkdir(parents=True)
|
||||
(git_source / "existing.txt").write_text("from git")
|
||||
@@ -156,13 +153,12 @@ class TestStackProfileMountsWithGitMounts:
|
||||
profile_mounts, git_mount_volumes
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert result == profile_mounts
|
||||
assert (git_source / "existing.txt").read_text() == "from git"
|
||||
assert (git_source / "settings.json").read_text() == "{}"
|
||||
assert not (git_source / "settings.json").exists()
|
||||
|
||||
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
|
||||
"""Profile mounts targeting a child directory are copied into the
|
||||
corresponding subdirectory of the git-mount source."""
|
||||
def test_descendant_overlap_keeps_sources_isolated(self, tmp_path) -> None:
|
||||
"""A child profile mount must not mutate the shared Git checkout."""
|
||||
git_source = tmp_path / "git"
|
||||
git_source.mkdir()
|
||||
(git_source / "README").write_text("repo")
|
||||
@@ -186,8 +182,8 @@ class TestStackProfileMountsWithGitMounts:
|
||||
profile_mounts, git_mount_volumes
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert (git_source / "agent" / "settings.json").read_text() == "x"
|
||||
assert result == profile_mounts
|
||||
assert not (git_source / "agent").exists()
|
||||
assert (git_source / "README").read_text() == "repo"
|
||||
|
||||
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
|
||||
@@ -247,9 +243,8 @@ class TestStackProfileMountsWithGitMounts:
|
||||
|
||||
assert result == profile_mounts
|
||||
|
||||
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
|
||||
"""A profile mount that supplies a single file is copied into the
|
||||
git-mount source directory."""
|
||||
def test_profile_source_file_does_not_mutate_git_source(self, tmp_path) -> None:
|
||||
"""A profile file must not be copied into a shared Git checkout."""
|
||||
git_source = tmp_path / "git"
|
||||
git_source.mkdir()
|
||||
|
||||
@@ -271,8 +266,8 @@ class TestStackProfileMountsWithGitMounts:
|
||||
profile_mounts, git_mount_volumes
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert (git_source / "settings.json").read_text() == "{}"
|
||||
assert result == profile_mounts
|
||||
assert not (git_source / "settings.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -2,7 +2,7 @@ import { apiClient } from "./client";
|
||||
|
||||
export interface ConfigProfileRefreshOutcome {
|
||||
instance_id: string;
|
||||
status: "compatible" | "restart_required" | "incompatible_permissions";
|
||||
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -145,6 +145,15 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/config-profiles/${id}`);
|
||||
};
|
||||
|
||||
export const refreshConfigProfileGitMounts = async (
|
||||
id: string,
|
||||
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
|
||||
const response = await apiClient.post<{
|
||||
refresh_outcomes: ConfigProfileRefreshOutcome[];
|
||||
}>(`/config-profiles/${id}/refresh-git-mounts`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProfileIncludes = async (
|
||||
id: string,
|
||||
data: UpdateIncludesRequest,
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Props {
|
||||
onSubmit: (e?: React.FormEvent) => void;
|
||||
onReset: () => void;
|
||||
onPreview: () => void;
|
||||
onRefreshGitMounts: () => void;
|
||||
onAddInclude: (id: string) => void;
|
||||
onRemoveInclude: (index: number) => void;
|
||||
onDragStart: (e: React.DragEvent, index: number) => void;
|
||||
@@ -63,6 +64,7 @@ export const ConfigProfileEditorPanel = ({
|
||||
onSubmit,
|
||||
onReset,
|
||||
onPreview,
|
||||
onRefreshGitMounts,
|
||||
onAddInclude,
|
||||
onRemoveInclude,
|
||||
onDragStart,
|
||||
@@ -114,6 +116,9 @@ export const ConfigProfileEditorPanel = ({
|
||||
</div>
|
||||
{!isCreating && selectedProfile && (
|
||||
<div className="row row-sm">
|
||||
<button className="btn btn-secondary" onClick={onRefreshGitMounts}>
|
||||
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||
{previewingId === selectedProfile.id ? (
|
||||
<><Icon name="loading" size="sm" /> Previewing...</>
|
||||
|
||||
@@ -64,6 +64,7 @@ interface Props {
|
||||
) => void;
|
||||
onRemoveMountFile: (mountIndex: number, path: string) => void;
|
||||
onPreview: (id: string) => void;
|
||||
onRefreshGitMounts: (id: string) => void;
|
||||
onClosePreview: () => void;
|
||||
}
|
||||
|
||||
@@ -104,6 +105,7 @@ export const ConfigProfilesMobileView = ({
|
||||
onUpdateMountFile,
|
||||
onRemoveMountFile,
|
||||
onPreview,
|
||||
onRefreshGitMounts,
|
||||
onClosePreview,
|
||||
}: Props) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -363,6 +365,13 @@ export const ConfigProfilesMobileView = ({
|
||||
onDelete={handleDeleteClick}
|
||||
>
|
||||
<div className="mobile-detail-actions-extra">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => onRefreshGitMounts(selectedProfile.id)}
|
||||
>
|
||||
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deleteConfigProfile,
|
||||
listConfigProfiles,
|
||||
previewConfigProfile,
|
||||
refreshConfigProfileGitMounts,
|
||||
updateConfigProfile,
|
||||
updateProfileIncludes,
|
||||
type ConfigProfile,
|
||||
@@ -273,6 +274,25 @@ export const useConfigProfiles = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await refreshConfigProfileGitMounts(id);
|
||||
const refreshed = result.refresh_outcomes.filter(
|
||||
(outcome) => outcome.status === "refreshed",
|
||||
);
|
||||
setError(
|
||||
refreshed.length
|
||||
? `Refreshed Git mounts for ${refreshed.length} running instance${refreshed.length === 1 ? "" : "s"}.`
|
||||
: "No running instances currently use this profile's Git mounts.",
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async (id: string) => {
|
||||
try {
|
||||
setPreviewingId(id);
|
||||
@@ -434,6 +454,7 @@ export const useConfigProfiles = () => {
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handlePreview,
|
||||
handleRefreshGitMounts,
|
||||
updateFormField,
|
||||
addEnvVar,
|
||||
updateEnvVar,
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ConfigProfilesPage = () => {
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handlePreview,
|
||||
handleRefreshGitMounts,
|
||||
updateFormField,
|
||||
addEnvVar,
|
||||
updateEnvVar,
|
||||
@@ -135,6 +136,7 @@ export const ConfigProfilesPage = () => {
|
||||
onUpdateMountFile={updateMountFile}
|
||||
onRemoveMountFile={removeMountFile}
|
||||
onPreview={handlePreview}
|
||||
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
|
||||
onClosePreview={() => setPreviewData(null)}
|
||||
/>
|
||||
);
|
||||
@@ -176,6 +178,7 @@ export const ConfigProfilesPage = () => {
|
||||
onSubmit={handleSubmit}
|
||||
onReset={handleReset}
|
||||
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
|
||||
onRefreshGitMounts={() => selectedProfile && handleRefreshGitMounts(selectedProfile.id)}
|
||||
onAddInclude={addInclude}
|
||||
onRemoveInclude={removeInclude}
|
||||
onDragStart={handleDragStart}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Live Git Config Mount Refresh
|
||||
|
||||
## Why
|
||||
|
||||
Git-backed Config Profile mounts are currently cloned per instance. Their content cannot be refreshed consistently for running sessions, and writable container mounts can dirty the checkout.
|
||||
|
||||
## Change
|
||||
|
||||
Move Git Config Profile mounts to profile-scoped canonical host clones. Bind the already-mounted directory sources read-only into compatible instances and refresh a stable branch/ref checkout in place under a per-clone lock.
|
||||
|
||||
## Scope
|
||||
|
||||
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
|
||||
- In-place refresh for existing directory mounts only.
|
||||
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
|
||||
- Read-only container Git config mounts.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Writable shared Git configuration mounts.
|
||||
- Global cross-user clone sharing.
|
||||
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
|
||||
- Atomic all-files revision switching for processes already reading the mount.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Design: Live Git Config Mount Refresh
|
||||
|
||||
## Canonical source
|
||||
|
||||
Each selected Config Profile owns canonical Git clone directories beneath:
|
||||
|
||||
```text
|
||||
<instance-root>/config-profiles/<profile-id>/git-mounts/<identity>/repo
|
||||
```
|
||||
|
||||
`identity` is a stable hash of normalized remote URL, requested ref, and credential scope. Sources are deliberately profile-scoped; clones are never shared across users.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
1. Resolve Git mounts and map them to canonical sources.
|
||||
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
|
||||
3. Clone into a temporary sibling, then rename on initial creation.
|
||||
4. For refresh, fetch and update the existing working tree in place.
|
||||
5. Bind directory mappings read-only. Existing containers see changed directory contents without recreation.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- URL/ref/source/target/mode changes, direct-file mappings, and changed glob result sets return `restart_required`.
|
||||
- Refresh failure is reported without mutating a known-good checkout.
|
||||
- No non-Git profile content may be copied into a Git checkout; overlapping targets are rejected or reported.
|
||||
- Containers must not write to shared Git mount sources.
|
||||
|
||||
## Security
|
||||
|
||||
Host Git operations use only an authorized server-side credential source. Credentials are not part of the mounted checkout and are not exposed to containers.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Live Git Config Mount Refresh — Tasks
|
||||
|
||||
- [x] Add canonical profile-scoped Git clone source planning and clone identity helpers.
|
||||
- [x] Make Git Config Profile mounts read-only and prevent profile-content copy into Git sources.
|
||||
- [x] Add lock-protected clone/fetch/ref checkout refresh that preserves a known-good checkout on failure.
|
||||
- [x] Add save/refresh outcomes for live refresh, restart-required topology, and failures.
|
||||
- [x] Add desktop/mobile feedback for refresh outcomes.
|
||||
- [ ] Add focused resolver/service/API/frontend tests.
|
||||
- [x] Run available verification and document skipped checks.
|
||||
|
||||
## Verification Notes
|
||||
|
||||
- Passed: frontend production build and Python compilation for changed backend modules.
|
||||
- Skipped: backend pytest and Ruff are unavailable in this environment; Docker/manual live-session checks were not approved.
|
||||
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.
|
||||
Reference in New Issue
Block a user