Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions crates/cli/src/cmd/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub(crate) struct CheckpointCreateArgs {
pub(crate) struct CheckpointRestoreArgs {
#[arg(long)]
pub id: String,
#[arg(long, help = "Force restore, overwriting local modifications")]
pub force: bool,
}

#[derive(Debug, Args)]
Expand All @@ -45,6 +47,8 @@ pub(crate) struct CheckpointBranchArgs {
pub id: String,
#[arg(long)]
pub name: String,
#[arg(long, help = "Force materialization, overwriting local modifications")]
pub force: bool,
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -105,7 +109,7 @@ async fn checkpoint_restore(args: CheckpointRestoreArgs) -> Result<()> {
let mut profile = load_profile()?;
let client = reqwest::Client::new();
let snapshot = fetch_checkpoint_snapshot(&client, &mut profile, &args.id).await?;
materialize_checkpoint_snapshot(&client, &mut profile, &snapshot).await?;
materialize_checkpoint_snapshot(&client, &mut profile, &snapshot, args.force).await?;
println!(
"checkpoint restored: checkpoint_id={} session_id={} repo_id={} branch={} asset_count={}",
snapshot.checkpoint_id,
Expand Down Expand Up @@ -140,7 +144,7 @@ async fn checkpoint_branch(args: CheckpointBranchArgs) -> Result<()> {
"create branch failed"
)));
}
materialize_checkpoint_snapshot(&client, &mut profile, &snapshot).await?;
materialize_checkpoint_snapshot(&client, &mut profile, &snapshot, args.force).await?;
profile.current_repo = Some(snapshot.repo_id.clone());
profile.current_branch = args.name.clone();
save_profile(&profile)?;
Expand Down
135 changes: 123 additions & 12 deletions crates/cli/src/cmd/sync.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use anyhow::Result;
use std::{collections::HashSet, fs, path::Path};

use anyhow::{anyhow, Context, Result};
use clap::Args;

use crate::utils::*;
Expand All @@ -11,6 +13,8 @@ pub(crate) struct SyncArgs {
pub branch: Option<String>,
#[arg(long = "to", help = "Optional changeset id to sync to")]
pub to_changeset_id: Option<String>,
#[arg(long, help = "Force sync, overwriting local modifications")]
pub force: bool,
}

pub(crate) async fn execute(args: SyncArgs) -> Result<()> {
Expand All @@ -19,6 +23,7 @@ pub(crate) async fn execute(args: SyncArgs) -> Result<()> {
let branch = args
.branch
.unwrap_or_else(|| profile.current_branch.clone());
let workspace_root = std::env::current_dir()?;
let client = reqwest::Client::new();
let snapshot = fetch_snapshot(
&client,
Expand All @@ -29,23 +34,129 @@ pub(crate) async fn execute(args: SyncArgs) -> Result<()> {
)
.await?;

// Preserve existing stage assets — only update base_changeset_id
let mut stage = load_stage().unwrap_or_else(|_| StageFile::default_for_branch(&branch));
stage.base_changeset_id = snapshot.changeset_id;
save_stage(&stage)?;
if let Ok(mut workspace) = load_workspace() {
if workspace.repo_id == repo && workspace.branch == branch {
workspace.base_changeset_id = stage.base_changeset_id.clone();
workspace.last_synced_at = now_unix();
save_workspace(&workspace)?;
let existing_workspace = load_workspace().ok().filter(|workspace| {
workspace.repo_id == repo
&& workspace.branch == branch
&& Path::new(&workspace.workspace_root) == workspace_root
});

// A base-pointer advance without reconciling file content silently discards
// intervening changes on the next submit. Refuse when local work would be
// clobbered so the user resolves it (submit / --force) rather than losing it.
if !args.force {
if let Ok(stage) = load_stage() {
if !stage.assets.is_empty() {
return Err(anyhow!(
"workspace has {} staged change(s); submit them before syncing or use --force",
stage.assets.len()
));
}
}
if let Some(workspace) = &existing_workspace {
let conflicts = detect_local_modifications(workspace)?;
if !conflicts.is_empty() {
eprintln!(
"error: workspace has {} uncommitted modification(s); sync would overwrite:",
conflicts.len()
);
for conflict in &conflicts {
eprintln!(" {}", conflict.path);
}
eprintln!("submit your changes, or re-run with --force to overwrite.");
return Err(anyhow!("sync refused to overwrite local changes"));
}
}
}

let snapshot_paths = snapshot
.assets
.iter()
.map(|asset| asset.path.as_str())
.collect::<HashSet<_>>();

// Guard against overwriting untracked local files that collide with the snapshot.
let tracked_paths = existing_workspace
.as_ref()
.map(|workspace| {
workspace
.checked_out_assets
.iter()
.map(|asset| asset.path.as_str())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if !args.force {
for asset in &snapshot.assets {
if tracked_paths.contains(asset.path.as_str()) {
continue;
}
let target = resolve_workspace_target(&workspace_root, &asset.path)?;
if target.exists()
&& (target.is_dir()
|| hash_local_asset(&workspace_root, &asset.path)?.as_deref()
!= Some(asset.blob_hash.as_str()))
{
return Err(anyhow!(
"sync would overwrite untracked local file {}; use --force",
asset.path
));
}
}
}

// Remove tracked files that no longer exist in the new snapshot.
if let Some(workspace) = &existing_workspace {
for asset in &workspace.checked_out_assets {
if snapshot_paths.contains(asset.path.as_str()) {
continue;
}
let target = resolve_workspace_target(&workspace_root, &asset.path)?;
if target.is_file() {
fs::remove_file(&target)
.with_context(|| format!("failed to delete {}", target.display()))?;
}
}
}

// Materialize snapshot content so recorded hashes and on-disk files agree with
// the advanced base pointer.
let mut checked_out_assets = Vec::with_capacity(snapshot.assets.len());
for asset in &snapshot.assets {
let target = resolve_workspace_target(&workspace_root, &asset.path)?;
let bytes = fetch_blob_bytes(&client, &mut profile, &asset.blob_hash).await?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, &bytes)
.with_context(|| format!("failed to write {}", target.display()))?;
checked_out_assets.push(WorkspaceFile {
path: asset.path.clone(),
blob_hash: asset.blob_hash.clone(),
asset_id: asset.asset_id.clone(),
});
}

let workspace = WorkspaceState {
repo_id: repo.clone(),
branch: branch.clone(),
workspace_root: workspace_root.to_string_lossy().to_string(),
base_changeset_id: snapshot.changeset_id.clone(),
checked_out_assets,
last_synced_at: now_unix(),
};
save_workspace(&workspace)?;

// Advance the base pointer; a clean workspace now has no staged assets.
let mut stage = StageFile::default_for_branch(&branch);
stage.base_changeset_id = snapshot.changeset_id.clone();
save_stage(&stage)?;

println!(
"synced {}@{} to {} ({} assets)",
repo,
branch,
stage
.base_changeset_id
snapshot
.changeset_id
.clone()
.unwrap_or_else(|| "ROOT".to_string()),
snapshot.assets.len()
Expand Down
90 changes: 86 additions & 4 deletions crates/cli/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,15 +603,26 @@ pub(crate) fn normalize_asset_path(path: &Path) -> String {
}

pub(crate) fn confirm_dangerous(action: &str, yes: bool) -> Result<()> {
use std::io::IsTerminal;

if yes {
return Ok(());
}
// Never silently "cancel" (as success) when there is no interactive terminal to
// prompt: automation that forgot --yes must get a hard error, not a no-op exit 0.
if !std::io::stdin().is_terminal() {
return Err(anyhow!(
"refusing dangerous operation ({action}) without confirmation; \
re-run with --yes to proceed non-interactively"
));
}
eprint!("dangerous operation: {}. confirm? [y/N] ", action);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().to_lowercase() != "y" {
eprintln!("cancelled.");
std::process::exit(0);
let read = std::io::stdin().read_line(&mut input)?;
if read == 0 || input.trim().to_lowercase() != "y" {
// Return an error so a declined operation exits non-zero instead of
// reporting success to any calling script.
return Err(anyhow!("operation cancelled by user"));
}
Ok(())
}
Expand Down Expand Up @@ -1692,8 +1703,10 @@ pub(crate) async fn materialize_checkpoint_snapshot(
client: &reqwest::Client,
profile: &mut CliProfile,
snapshot: &CheckpointSnapshot,
force: bool,
) -> Result<()> {
let workspace_root = std::env::current_dir()?;
guard_checkpoint_overwrite(&workspace_root, snapshot, force)?;
let mut checked_out_assets = Vec::with_capacity(snapshot.assets.len());
for asset in &snapshot.assets {
let target = resolve_workspace_target(&workspace_root, &asset.path)?;
Expand Down Expand Up @@ -1723,6 +1736,75 @@ pub(crate) async fn materialize_checkpoint_snapshot(
Ok(())
}

/// Refuse to overwrite local work when restoring/branching from a checkpoint,
/// mirroring the pre-flight in `ht checkout`. Bypassed only with `force`.
fn guard_checkpoint_overwrite(
workspace_root: &Path,
snapshot: &CheckpointSnapshot,
force: bool,
) -> Result<()> {
if force {
return Ok(());
}

if let Ok(stage) = load_stage() {
if !stage.assets.is_empty() {
return Err(anyhow!(
"workspace has {} staged change(s); submit them or use --force",
stage.assets.len()
));
}
}

let existing_workspace = load_workspace().ok();
let matching_workspace = existing_workspace.as_ref().filter(|workspace| {
workspace.repo_id == snapshot.repo_id
&& Path::new(&workspace.workspace_root) == workspace_root
});

let mut tracked_paths = std::collections::HashSet::new();
if let Some(workspace) = matching_workspace {
let conflicts = detect_local_modifications(workspace)?;
if !conflicts.is_empty() {
eprintln!(
"error: workspace has {} uncommitted modification(s), restore would overwrite:",
conflicts.len()
);
for conflict in &conflicts {
eprintln!(" {}", conflict.path);
}
eprintln!("commit/submit your changes, or re-run with --force to overwrite.");
return Err(anyhow!(
"checkpoint restore refused to overwrite local changes"
));
}
tracked_paths.extend(
workspace
.checked_out_assets
.iter()
.map(|asset| asset.path.as_str()),
);
}

for asset in &snapshot.assets {
if tracked_paths.contains(asset.path.as_str()) {
continue;
}
let target = resolve_workspace_target(workspace_root, &asset.path)?;
if target.exists()
&& (target.is_dir()
|| hash_local_asset(workspace_root, &asset.path)?.as_deref()
!= Some(asset.blob_hash.as_str()))
{
return Err(anyhow!(
"checkpoint restore would overwrite untracked local file {}; use --force",
asset.path
));
}
}
Ok(())
}

// ── Lock helper ──

pub(crate) async fn send_lock_path_request(
Expand Down
55 changes: 54 additions & 1 deletion crates/cli/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,42 @@ pub fn ensure_state_dirs(paths: &StatePaths) -> Result<()> {
if !paths.state_dir.exists() {
fs::create_dir_all(&paths.state_dir)?;
}
// Restrict the state directory to the owner: it holds credentials.
harden_dir_permissions(&paths.state_dir);
// Never let the local state (including plaintext credentials) be committed.
ensure_state_gitignore(&paths.state_dir);
if !paths.cache_dir.exists() {
fs::create_dir_all(&paths.cache_dir)?;
}
Ok(())
}

fn ensure_state_gitignore(state_dir: &Path) {
let gitignore = state_dir.join(".gitignore");
if !gitignore.exists() {
// Ignore everything under .hypertide/, including this file itself.
let _ = fs::write(&gitignore, "*\n");
}
}

#[cfg(unix)]
fn harden_dir_permissions(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
}

#[cfg(not(unix))]
fn harden_dir_permissions(_dir: &Path) {}

#[cfg(unix)]
fn harden_file_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}

#[cfg(not(unix))]
fn harden_file_permissions(_path: &Path) {}

pub fn load_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
let content =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
Expand All @@ -44,10 +74,33 @@ pub fn save_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec_pretty(value)?)?;
let bytes = serde_json::to_vec_pretty(value)?;
// Atomic write: serialize to a sibling temp file, tighten permissions before
// it holds any data, then rename over the target so a crash/IO error mid-write
// can never truncate or corrupt existing state (e.g. profile.json credentials).
let temp_path = temp_sibling(path);
fs::write(&temp_path, &bytes)
.with_context(|| format!("failed to write {}", temp_path.display()))?;
harden_file_permissions(&temp_path);
if let Err(err) = fs::rename(&temp_path, path) {
let _ = fs::remove_file(&temp_path);
return Err(err).with_context(|| format!("failed to replace {}", path.display()));
}
Ok(())
}

fn temp_sibling(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "state".to_string());
let temp_name = format!(".{}.tmp.{}", file_name, std::process::id());
match path.parent() {
Some(parent) => parent.join(temp_name),
None => PathBuf::from(temp_name),
}
}

pub fn cache_object_path(paths: &StatePaths, hash: &str) -> PathBuf {
paths.cache_dir.join(hash)
}
Loading