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
20 changes: 17 additions & 3 deletions src/daemon/ref_cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -822,9 +822,9 @@ impl RefCursor {

if matches!(kind, "apply" | "pop" | "drop" | "branch") {
let target = if kind == "branch" {
stash_args.get(2)
stash_positional_arg(stash_args, 1)
} else {
stash_args.get(1)
stash_positional_arg(stash_args, 0)
};
cmd.stash_target_oid = self.resolve_stash_target_at_cursor(target)?;
}
Expand All @@ -836,7 +836,7 @@ impl RefCursor {
cmd.ref_changes.push(entry_to_ref_change(&entry));
}
} else if matches!(kind, "pop" | "drop") {
self.consume_destructive_stash_operation(stash_args.get(1), cmd)?;
self.consume_destructive_stash_operation(stash_positional_arg(stash_args, 0), cmd)?;
}

if matches!(kind, "apply" | "pop" | "branch")
Expand Down Expand Up @@ -3334,6 +3334,20 @@ fn stash_command_args(args: &[String]) -> &[String] {
}
}

/// Positional (non-flag) argument after the stash subcommand. pop/apply/drop
/// take only boolean flags (`-q`, `--index`), so the stash target is the
/// first positional, not literally `args[1]` -- `git stash pop -q` must not
/// treat `-q` as the target (that made resolution fail and skipped the
/// attribution restore entirely). For `branch` the branch name is positional 0
/// and the optional stash target is positional 1.
fn stash_positional_arg(stash_args: &[String], position: usize) -> Option<&String> {
stash_args
.iter()
.skip(1)
.filter(|arg| !arg.starts_with('-'))
.nth(position)
}

fn stash_push_message_from_args(args: &[String], kind: &str) -> Option<String> {
if kind == "save" {
let message = args
Expand Down
90 changes: 89 additions & 1 deletion src/git/repo_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,30 @@ impl RepoStorage {
}
new_log.write_initial(merged_initial)?;

// A log can carry the same checkpoints on both sides (e.g. merged
// forward and back across HEAD moves), so dedup by identity key --
// `diff` is a content hash, so (kind, diff, timestamp, author) never
// collides for distinct checkpoints.
let mut checkpoints = old_log.read_all_checkpoints()?;
checkpoints.extend(new_log.read_all_checkpoints()?);
let seen: HashSet<(String, String, u64, String)> = checkpoints
.iter()
.map(|c| {
(
c.kind.to_string(),
c.diff.clone(),
c.timestamp,
c.author.clone(),
)
})
.collect();
checkpoints.extend(new_log.read_all_checkpoints()?.into_iter().filter(|c| {
!seen.contains(&(
c.kind.to_string(),
c.diff.clone(),
c.timestamp,
c.author.clone(),
))
}));
new_log.write_all_checkpoints(&checkpoints)?;
Ok(())
}
Expand Down Expand Up @@ -875,4 +897,70 @@ mod tests {
assert!(merged.files.contains_key("old_only.txt"));
assert!(merged.files.contains_key("new_only.txt"));
}

fn checkpoint(diff: &str, timestamp: u64) -> Checkpoint {
let entry = crate::authorship::working_log::WorkingLogEntry::new(
"file.txt".to_string(),
"blobsha".to_string(),
Vec::new(),
Vec::new(),
);
let mut cp = Checkpoint::new(
CheckpointKind::AiAgent,
diff.to_string(),
"author".to_string(),
vec![entry],
);
cp.timestamp = timestamp;
cp
}

/// When both working logs carry the same checkpoints (e.g. a log that was
/// merged forward and back across HEAD moves), merging must not duplicate
/// them -- only checkpoints missing from the destination are appended.
#[test]
fn test_merge_working_log_dirs_dedups_identical_checkpoints() {
let tmp = TempDir::new().unwrap();
let workdir = tmp.path().join("workdir");
fs::create_dir_all(&workdir).unwrap();
let ai_dir = tmp.path().join("ai");
let storage = RepoStorage::for_repo_path(&ai_dir, &workdir).unwrap();

let old_sha = "1111111111111111111111111111111111111111";
let new_sha = "2222222222222222222222222222222222222222";

let shared_a = checkpoint("diff-shared-a", 100);
let shared_b = checkpoint("diff-shared-b", 200);
let old_only = checkpoint("diff-old-only", 300);
let new_only = checkpoint("diff-new-only", 400);

let old_log = storage.working_log_for_base_commit(old_sha).unwrap();
old_log
.write_all_checkpoints(&[shared_a.clone(), shared_b.clone(), old_only])
.unwrap();
let new_log = storage.working_log_for_base_commit(new_sha).unwrap();
new_log
.write_all_checkpoints(&[shared_a, shared_b, new_only])
.unwrap();

storage.rename_working_log(old_sha, new_sha).unwrap();

let merged = storage
.working_log_for_base_commit(new_sha)
.unwrap()
.read_all_checkpoints()
.unwrap();
let mut diffs: Vec<&str> = merged.iter().map(|c| c.diff.as_str()).collect();
diffs.sort_unstable();
assert_eq!(
diffs,
vec![
"diff-new-only",
"diff-old-only",
"diff-shared-a",
"diff-shared-b"
],
"shared checkpoints must not duplicate on merge"
);
}
}
Loading
Loading