Skip to content
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **`rm` no longer counts a commit and the commit that reverts it** (#664). Neither has a
copy on a remote, and a branch that changes nothing proves nothing to the squash rule,
so after #653 and #659 `rm` and `dl --ls --json` still counted both. Now a counted
commit whose tree is the tree under the counted commit it sits on drops out with that
commit, when both have one parent and the lower one changes something. A commit is in
one pair at most and the pairs are taken from the bottom, so a revert of a revert
still counts. A ref or a worktree's HEAD on the reverted commit holds the pair back,
and so does a second commit that grew from it, such as a branch or the stash. A merge,
a root commit, an empty commit and a revert of a commit that another rule cleared
still count, and so does an earlier draft of a commit that was edited later.

## [0.59.0] - 2026-09-30

### Added
Expand Down
25 changes: 21 additions & 4 deletions docs/cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ cherry-pick and a squash merge all put the same change on the remote under a new
hash, so by hash the old commit is on no remote ref. A kinisi_ros workspace refused
`rm` over 23 commits that way, and none of them was lost: its remote branch had
been rebased, a merged PR had been squashed into `main`, and five of the commits
were merges of `origin/main`. Three more rules now run after the count:
were merges of `origin/main`. Four more rules now run after the count:

- A commit drops out when a remote ref holds a copy of it: a commit that makes
the same change in the same place. The candidates come from the patch id,
Expand Down Expand Up @@ -1032,12 +1032,29 @@ were merges of `origin/main`. Three more rules now run after the count:
branch's first-parent line, so work added after the squash stays counted and
the squashed commits under it do not. A branch that changes nothing since it
left the remote ref proves nothing, because any merge of it gives the remote
ref's tree, so a commit and its revert on their own still count. A commit
ref's tree, so this rule leaves a commit and its revert on their own to the
next one. A commit
drops out only when every ref that reaches it is a branch that passed: a
second branch that grew from it and does not pass holds it back, and so do
the stash, a local tag and a detached HEAD.

All three rules can only take commits out of the count, and only when git
- A commit and the commit that reverts it drop out together. Together they
change nothing, so a push of them would change nothing either. A kinisi_ros
workspace refused `rm` over a probe commit and its revert, left on a backup
branch after the PR was squashed. The pair is two counted commits with one
parent each, the second on top of the first, where the second's tree is the
tree under the first and the first's tree is not. Trees, not patches, so the
revert must take back the whole change, byte for byte. An empty commit is
never the first of a pair, because its message is all it holds. A root
commit is never one either, because there is no tree under it, and nor is a
merge on either side. When another rule already cleared the first commit,
the revert stays counted: it is the only record of taking that change out
again. A commit is in one pair at most, and the pairs are taken from the
bottom, so a revert of a revert stays counted, because it puts the change
back. The pair stays counted when anything else holds the state of the
first commit: a branch, a tag or a worktree's HEAD on it, or a second
commit that grew from it, such as another branch or the stash.

All four rules can only take commits out of the count, and only when git
answers. A question git refuses clears nothing, and so does a merge with a
conflict. That is the limit of the third rule: when the remote edited the lines
the squash wrote, the merge conflicts, and the squashed commits still count,
Expand Down
117 changes: 116 additions & 1 deletion rust/devlaunch-core/src/clients/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
//! module, so the spans are wired in M4b/M5 against the real registry rather than
//! guessed at here. The names above are the list to wire.

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
Expand Down Expand Up @@ -1230,6 +1230,51 @@ impl<'r> Git<'r> {
.map(|stdout| stdout.lines().map(str::to_owned).collect())
}

/// Every commit no remote-tracking ref contains, with its tree and its
/// parents, and the tree of each pushed commit one of them has as a parent.
///
/// What the revert rule reads: a commit and its revert, and whether any
/// other commit grew from the reverted one. `--all` with nothing excluded,
/// so a commit that only a tag or `refs/original` reaches is still in the
/// graph. When that tag came from the remote, or it is `refs/original`, such
/// a commit is never counted, so it can only hold a pair back.
/// `--boundary` lists the pushed parents too, marked `-`, which is where the
/// tree under a commit made on top of the remote comes from.
///
/// `--no-show-signature` because `log.showSignature` would put gpg's lines
/// between the commits. A line in any other shape reads as no graph at
/// all (`None`): a commit left out of it could be the one that holds a pair
/// back.
pub(crate) fn unpushed_graph(&self, clone: &Path) -> GitAnswer<Option<CommitGraph>> {
self.about(
clone,
&[
"log",
"--no-color",
"--no-show-signature",
"--boundary",
"--format=%m %H %T %P",
"--all",
"--not",
"--remotes",
],
)
.map(|stdout| commit_graph_in(&stdout))
}

/// The commit each ref and each worktree's HEAD names, tags peeled.
///
/// The revert rule's other half: a ref on the reverted commit holds the
/// state with that commit's change in it. `rev-list --all` reads every
/// ref under `refs/` and the HEAD of every worktree, detached or not, and
/// `--no-walk` lists the tips without their history. A linked worktree's
/// own `refs/worktree/*` and `refs/bisect/*` are not read, as the count
/// does not read them either.
pub(crate) fn ref_tips(&self, clone: &Path) -> GitAnswer<HashSet<String>> {
self.about(clone, &["rev-list", "--no-walk", "--all"])
.map(|stdout| stdout.lines().map(str::to_owned).collect())
}

/// Every tag in the bare cache at *bare*, with the object each one names.
///
/// `--git-dir` and no work tree, because a bare has none, and no cwd, because
Expand Down Expand Up @@ -2201,6 +2246,76 @@ impl RemoteRef {
}
}

/// The unpushed commits of a clone, from [`Git::unpushed_graph`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CommitGraph {
/// Each commit no remote-tracking ref contains, by full hash.
unpushed: HashMap<String, GraphCommit>,
/// The tree of each pushed commit that an unpushed one has as a parent.
pushed_trees: HashMap<String, String>,
}

/// One unpushed commit in a [`CommitGraph`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GraphCommit {
pub(crate) tree: String,
/// Full hashes, the first parent first.
pub(crate) parents: Vec<String>,
}

impl CommitGraph {
/// Every unpushed commit, by full hash.
pub(crate) fn unpushed(&self) -> impl Iterator<Item = (&str, &GraphCommit)> {
self.unpushed
.iter()
.map(|(hash, commit)| (hash.as_str(), commit))
}

/// The unpushed commit *hash*, or `None` when it is pushed or unknown.
pub(crate) fn commit(&self, hash: &str) -> Option<&GraphCommit> {
self.unpushed.get(hash)
}

/// The tree of *hash*, pushed or not, when the graph lists it.
pub(crate) fn tree_of(&self, hash: &str) -> Option<&str> {
self.unpushed
.get(hash)
.map(|commit| commit.tree.as_str())
.or_else(|| self.pushed_trees.get(hash).map(String::as_str))
}
}

/// The graph in [`Git::unpushed_graph`] output, one `<mark> <hash> <tree>
/// <parents>` line per commit, or `None` when a line is in any other shape.
fn commit_graph_in(output: &str) -> Option<CommitGraph> {
let mut graph = CommitGraph {
unpushed: HashMap::new(),
pushed_trees: HashMap::new(),
};
for line in output.lines() {
let mut fields = line.split(' ').filter(|field| !field.is_empty());
let (Some(mark), Some(hash), Some(tree)) = (fields.next(), fields.next(), fields.next())
else {
return None;
};
let parents: Vec<String> = fields.map(str::to_owned).collect();
match mark {
">" => {
let commit = GraphCommit {
tree: tree.to_owned(),
parents,
};
graph.unpushed.insert(hash.to_owned(), commit);
}
"-" => {
graph.pushed_trees.insert(hash.to_owned(), tree.to_owned());
}
_ => return None,
}
}
Some(graph)
}

/// What [`Git::text_merge`] found a clone can merge with: the `--attr-source`
/// that names the empty tree in the clone's object format.
#[derive(Clone, Debug, PartialEq, Eq)]
Expand Down
127 changes: 127 additions & 0 deletions rust/devlaunch-core/src/domain/workspace_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,10 @@ fn unsaved(git: &Git<'_>, clone: &Path, bare: BareCache<'_>) -> Unsaved {
if !left.is_empty() {
copied.extend(squashed_onto_a_remote(git, clone, &left, &local_tags));
}
let left = not_among(listed.iter(), &copied);
if !left.is_empty() {
copied.extend(reverted_in_pairs(git, clone, &left));
}
if let Some(commits) = NonEmpty::of(not_among(listed.iter(), &copied)) {
let by_tags = owed_to_tags(git, clone, &local_tags)
.and_then(|by_tags| by_tags.leaving_out(&copied));
Expand Down Expand Up @@ -797,6 +801,129 @@ fn squashed_onto_a_remote(
cleared
}

/// The counted commits that come in pairs of a commit and its revert, as full
/// hashes.
///
/// What [`already_on_a_remote`] and [`squashed_onto_a_remote`] leave: a commit
/// and the commit that reverts it. Neither has a copy on a remote, and a branch
/// that changes nothing proves nothing to the squash rule. A kinisi_ros
/// workspace refused `rm` over a probe commit and its revert that way, with
/// the PR (kinisi_ros#11898) squashed into `main`. Together the two commits
/// change nothing, so a push of them would change nothing either.
///
/// **A pair** is a counted commit *R* whose one parent is a counted commit *C*
/// with one parent of its own, where *R*'s tree is the tree under *C* and
/// *C*'s tree is not. Trees, not patches, so the revert must take back the
/// whole change, byte for byte. So:
///
/// - an empty *C* is no pair, because an empty commit holds only its message;
/// - a root *C* is no pair, because there is no tree under it;
/// - a merge is no pair on either side, because its change is not one
/// commit's;
/// - a *C* that another rule cleared is no pair, because then *R* is the one
/// record of taking a change on a remote out again.
///
/// **A commit is in one pair at most, and the pairs are taken from the
/// bottom.** A revert of a revert puts the change back, so the third commit
/// holds what the first did and stays counted while the first two drop out.
///
/// **A pair drops out only when nothing else holds the state of *C*.** A ref or
/// a worktree's HEAD on *C* holds it, and so does a second commit that grew
/// from *C*: a branch, or the stash. Then both commits stay counted. Every ref
/// that reaches *C* then reaches it through *R*. "Every ref" is every ref
/// `--all` reads, as for the count: a linked worktree's own `refs/worktree/*`
/// and `refs/bisect/*` are not among them.
///
/// A cycle in the graph, which only `refs/replace` or grafts can make, holds
/// no pair.
///
/// It runs only when the other rules left a commit counted, and it costs one
/// `git log` of the unpushed graph, and one `rev-list` of the ref tips only
/// when a pair is found. **Every failure clears nothing**: a refusal on either,
/// or a graph in a shape this cannot read.
fn reverted_in_pairs(git: &Git<'_>, clone: &Path, counted: &[String]) -> Vec<String> {
let Some(Some(graph)) = git.unpushed_graph(clone).said() else {
return Vec::new();
};
let is_counted = |hash: &str| {
graph.commit(hash).is_some()
&& counted
.iter()
.any(|line| is_among(line, std::slice::from_ref(&hash.to_owned())))
};
let reverts = |hash: &str| -> Option<String> {
let revert = graph.commit(hash)?;
let [reverted] = revert.parents.as_slice() else {
return None;
};
let [under] = graph.commit(reverted)?.parents.as_slice() else {
return None;
};
let under_tree = graph.tree_of(under)?;
let changed = graph.tree_of(reverted)? != under_tree;
(changed && revert.tree == under_tree && is_counted(hash) && is_counted(reverted))
.then(|| reverted.clone())
};
struct Pair {
reverted: String,
revert: String,
}
let mut taken: HashMap<String, Option<Pair>> = HashMap::new();
for (hash, _) in graph.unpushed() {
let mut chain: Vec<Pair> = Vec::new();
let mut at = hash.to_owned();
let mut walked: HashSet<String> = HashSet::new();
while !taken.contains_key(&at) {
if !walked.insert(at.clone()) {
// A cycle, which only `refs/replace` or grafts can make: git
// shows the replaced parents. Nothing on it is a pair.
for pair in chain.drain(..) {
taken.insert(pair.revert, None);
}
break;
}
match reverts(&at) {
Some(reverted) => {
chain.push(Pair {
reverted: reverted.clone(),
revert: at,
});
at = reverted;
}
None => {
taken.insert(at.clone(), None);
}
}
}
for pair in chain.into_iter().rev() {
let under_a_pair = matches!(taken.get(&pair.reverted), Some(Some(_)));
taken.insert(pair.revert.clone(), (!under_a_pair).then_some(pair));
}
}
let pairs: Vec<Pair> = taken.into_values().flatten().collect();
if pairs.is_empty() {
return Vec::new();
}
let Some(tips) = git.ref_tips(clone).said() else {
return Vec::new();
};
let mut children: HashMap<&str, usize> = HashMap::new();
for (_, commit) in graph.unpushed() {
for parent in &commit.parents {
*children.entry(parent.as_str()).or_default() += 1;
}
}
let mut cleared: Vec<String> = pairs
.into_iter()
.filter(|pair| {
!tips.contains(&pair.reverted) && children.get(pair.reverted.as_str()) == Some(&1)
})
.flat_map(|pair| [pair.reverted, pair.revert])
.collect();
cleared.sort();
cleared
}

/// How far down a branch [`squashed_onto_a_remote`] looks for a commit that
/// passes.
///
Expand Down
Loading
Loading