Skip to content

Commit

Permalink
rewrite: incomplete() revset for updating author timestamp
Browse files Browse the repository at this point in the history
It's common to create empty working-copy commits while using jj, and
currently the author timestamp for a commit is only set when it is first
created. If you create an empty commit, then don't work on a repo for a
few days, and then start working on a new feature without abandoning the
working-copy commit, the author timestamp will remain as the time the
commit was created rather than being updated to the time that work began
or finished.

This commit adds a new revset `incomplete()` which specifies which
commits are "incomplete" and should have their author timestamp updated
whenever changes are made to them. This can be configured to fit many
different workflows. By default, empty commits with no description are
considered incomplete, meaning that the author timestamp will become
finalized whenever a commit is given a description or becomes non-empty.

The author timestamp is only updated if the committer is the author, and
it is never updated when rebasing.
  • Loading branch information
scott2000 committed Jun 17, 2024
1 parent 5d307e6 commit 28f4b38
Show file tree
Hide file tree
Showing 21 changed files with 139 additions and 22 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* `jj file` now groups commands for working with files.

* `incomplete()` revset can be used to configure when author timestamps should
be updated when rewriting commits.

### Fixed bugs

## [0.18.0] - 2024-06-05
Expand Down
2 changes: 1 addition & 1 deletion cli/examples/custom-command/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ fn run_custom_command(
let mut tx = workspace_command.start_transaction();
let new_commit = tx
.mut_repo()
.rewrite_commit(command_helper.settings(), &commit)
.rewrite_commit(command_helper.settings(), &commit, false)
.set_description("Frobnicated!")
.write()?;
tx.finish(ui, "Frobnicate")?;
Expand Down
49 changes: 47 additions & 2 deletions cli/src/cli_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use std::rc::Rc;
use std::str::FromStr;
use std::sync::Arc;
use std::time::SystemTime;
use std::{fs, str};
use std::{fs, iter, str};

use clap::builder::{
MapValueParser, NonEmptyStringValueParser, TypedValueParser, ValueParserFactory,
Expand Down Expand Up @@ -1156,8 +1156,37 @@ impl WorkspaceCommandHelper {
self.check_repo_rewritable(self.repo().as_ref(), commits)
}

pub fn find_incomplete<'a>(
&self,
commits: impl IntoIterator<Item = &'a CommitId>,
) -> Result<Vec<CommitId>, CommandError> {
let incomplete = revset_util::parse_incomplete_expression(&self.revset_parse_context())
.map_err(|e| config_error_with_message("Invalid `revset-aliases.incomplete()`", e))?;
let mut expression = RevsetExpressionEvaluator::new(
self.repo().as_ref(),
self.revset_extensions.clone(),
self.id_prefix_context()?,
incomplete,
);
expression.intersect_with(&RevsetExpression::commits(
commits.into_iter().cloned().collect_vec(),
));
Ok(expression
.evaluate_to_commit_ids()
.map_err(|e| config_error_with_message("Invalid `revset-aliases.incomplete()`", e))?
.collect_vec())
}

pub fn check_commit_incomplete(&self, commit_id: &CommitId) -> Result<bool, CommandError> {
Ok(!self.find_incomplete(iter::once(commit_id))?.is_empty())
}

#[instrument(skip_all)]
fn snapshot_working_copy(&mut self, ui: &mut Ui) -> Result<(), CommandError> {
let incomplete = revset_util::parse_incomplete_expression(&self.revset_parse_context())
.map_err(|e| config_error_with_message("Invalid `revset-aliases.incomplete()`", e))?;
let id_prefix_context = self.id_prefix_context()?.clone();

let workspace_id = self.workspace_id().to_owned();
let get_wc_commit = |repo: &ReadonlyRepo| -> Result<Option<_>, _> {
repo.view()
Expand Down Expand Up @@ -1228,12 +1257,28 @@ See https://github.com/martinvonz/jj/blob/main/docs/working-copy.md#stale-workin
})?;
drop(progress);
if new_tree_id != *wc_commit.tree_id() {
let update_author_timestamp = {
let mut expression = RevsetExpressionEvaluator::new(
self.user_repo.repo.as_ref(),
self.revset_extensions.clone(),
&id_prefix_context,
incomplete,
);
expression.intersect_with(&RevsetExpression::commit(wc_commit.id().clone()));
expression
.evaluate_to_commit_ids()
.map_err(|e| {
config_error_with_message("Invalid `revset-aliases.incomplete()`", e)
})?
.next()
.is_some()
};
let mut tx =
start_repo_transaction(&self.user_repo.repo, &self.settings, &self.string_args);
tx.set_is_snapshot(true);
let mut_repo = tx.mut_repo();
let commit = mut_repo
.rewrite_commit(&self.settings, &wc_commit)
.rewrite_commit(&self.settings, &wc_commit, update_author_timestamp)
.set_tree_id(new_tree_id)
.write()?;
mut_repo.set_wc_commit(workspace_id, commit.id().clone())?;
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub(crate) fn cmd_commit(
let advanceable_branches = workspace_command.get_advanceable_branches(commit.parent_ids())?;
let diff_selector =
workspace_command.diff_selector(ui, args.tool.as_deref(), args.interactive)?;
let update_author_timestamp = workspace_command.check_commit_incomplete(commit.id())?;
let mut tx = workspace_command.start_transaction();
let base_tree = commit.parent_tree(tx.repo())?;
let instructions = format!(
Expand Down Expand Up @@ -104,7 +105,7 @@ new working-copy commit.

let new_commit = tx
.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(command.settings(), &commit, update_author_timestamp)
.set_tree_id(tree_id)
.set_description(description)
.write()?;
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/describe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ pub(crate) fn cmd_describe(
if description == *commit.description() && !args.reset_author {
writeln!(ui.status(), "Nothing changed.")?;
} else {
let update_author_timestamp = workspace_command.check_commit_incomplete(commit.id())?;
let mut tx = workspace_command.start_transaction();
let mut commit_builder = tx
.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(command.settings(), &commit, update_author_timestamp)
.set_description(description);
if args.reset_author {
let new_author = commit_builder.committer().clone();
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/diffedit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ pub(crate) fn cmd_diffedit(
workspace_command.check_rewritable([target_commit.id()])?;

let diff_editor = workspace_command.diff_editor(ui, args.tool.as_deref())?;
let update_author_timestamp = workspace_command.check_commit_incomplete(target_commit.id())?;
let mut tx = workspace_command.start_transaction();
let instructions = format!(
"\
Expand All @@ -107,7 +108,7 @@ don't make any changes, then the operation will be aborted.",
} else {
let mut_repo = tx.mut_repo();
let new_commit = mut_repo
.rewrite_commit(command.settings(), &target_commit)
.rewrite_commit(command.settings(), &target_commit, update_author_timestamp)
.set_tree_id(tree_id)
.write()?;
// rebase_descendants early; otherwise `new_commit` would always have
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/duplicate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pub(crate) fn cmd_duplicate(
.map(|id| duplicated_old_to_new.get(id).map_or(id, |c| c.id()).clone())
.collect();
let new_commit = mut_repo
.rewrite_commit(command.settings(), &original_commit)
.rewrite_commit(command.settings(), &original_commit, false)
.generate_new_change_id()
.set_parents(new_parents)
.write()?;
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/file/chmod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub(crate) fn cmd_chmod(
let matcher = fileset_expression.to_matcher();
print_unmatched_explicit_paths(ui, &workspace_command, &fileset_expression, [&tree])?;

let update_author_timestamp = workspace_command.check_commit_incomplete(commit.id())?;
let mut tx = workspace_command.start_transaction();
let store = tree.store();
let mut tree_builder = MergedTreeBuilder::new(commit.tree_id().clone());
Expand Down Expand Up @@ -123,7 +124,7 @@ pub(crate) fn cmd_chmod(

let new_tree_id = tree_builder.write_tree(store)?;
tx.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(command.settings(), &commit, update_author_timestamp)
.set_tree_id(new_tree_id)
.write()?;
tx.finish(
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,12 @@ pub(crate) fn cmd_resolve(
"Resolving conflicts in: {}",
workspace_command.format_file_path(repo_path)
)?;
let update_author_timestamp = workspace_command.check_commit_incomplete(commit.id())?;
let mut tx = workspace_command.start_transaction();
let new_tree_id = merge_editor.edit_file(&tree, repo_path)?;
let new_commit = tx
.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(command.settings(), &commit, update_author_timestamp)
.set_tree_id(new_tree_id)
.write()?;
tx.finish(
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,11 @@ pub(crate) fn cmd_restore(
if &new_tree_id == to_commit.tree_id() {
writeln!(ui.status(), "Nothing changed.")?;
} else {
let update_author_timestamp = workspace_command.check_commit_incomplete(to_commit.id())?;
let mut tx = workspace_command.start_transaction();
let mut_repo = tx.mut_repo();
let new_commit = mut_repo
.rewrite_commit(command.settings(), &to_commit)
.rewrite_commit(command.settings(), &to_commit, update_author_timestamp)
.set_tree_id(new_tree_id)
.write()?;
// rebase_descendants early; otherwise `new_commit` would always have
Expand Down
5 changes: 3 additions & 2 deletions cli/src/commands/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub(crate) fn cmd_split(
args.tool.as_deref(),
args.interactive || args.paths.is_empty(),
)?;
let update_author_timestamp = workspace_command.check_commit_incomplete(commit.id())?;
let mut tx = workspace_command.start_transaction();
let end_tree = commit.tree()?;
let base_tree = commit.parent_tree(tx.repo())?;
Expand Down Expand Up @@ -132,7 +133,7 @@ the operation will be aborted.
let first_description = edit_description(tx.base_repo(), &first_template, command.settings())?;
let first_commit = tx
.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(command.settings(), &commit, update_author_timestamp)
.set_tree_id(selected_tree_id)
.set_description(first_description)
.write()?;
Expand Down Expand Up @@ -169,7 +170,7 @@ the operation will be aborted.
};
let second_commit = tx
.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(command.settings(), &commit, true)
.set_parents(second_commit_parents)
.set_tree_id(second_tree.id())
// Generate a new change id so that the commit being split doesn't
Expand Down
22 changes: 20 additions & 2 deletions cli/src/commands/squash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use std::iter;

use itertools::Itertools as _;
use jj_lib::commit::{Commit, CommitIteratorExt};
use jj_lib::matchers::Matcher;
Expand Down Expand Up @@ -249,6 +251,14 @@ from the source will be moved into the destination.
return Ok(());
}

let incomplete_commits = tx.base_workspace_helper().find_incomplete(
source_commits
.iter()
.filter(|source| !source.abandon)
.map(|source| source.commit.id())
.chain(iter::once(destination.id())),
)?;

for source in &source_commits {
if source.abandon {
tx.mut_repo()
Expand All @@ -258,7 +268,11 @@ from the source will be moved into the destination.
// Apply the reverse of the selected changes onto the source
let new_source_tree = source_tree.merge(&source.selected_tree, &source.parent_tree)?;
tx.mut_repo()
.rewrite_commit(settings, source.commit)
.rewrite_commit(
settings,
source.commit,
incomplete_commits.contains(source.commit.id()),
)
.set_tree_id(new_source_tree.id().clone())
.write()?;
}
Expand Down Expand Up @@ -300,7 +314,11 @@ from the source will be moved into the destination.
.map(|source| source.commit.id().clone()),
);
tx.mut_repo()
.rewrite_commit(settings, &rewritten_destination)
.rewrite_commit(
settings,
&rewritten_destination,
incomplete_commits.contains(destination.id()),
)
.set_tree_id(destination_tree.id().clone())
.set_predecessors(predecessors)
.set_description(description)
Expand Down
20 changes: 17 additions & 3 deletions cli/src/commands/unsquash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,36 @@ aborted.
tx.mut_repo().record_abandoned_commit(parent.id().clone());
let description =
combine_messages(tx.base_repo(), &[&parent], &commit, command.settings())?;
let update_author_timestamp = tx
.base_workspace_helper()
.check_commit_incomplete(commit.id())?;
// Commit the new child on top of the parent's parents.
tx.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(command.settings(), &commit, update_author_timestamp)
.set_parents(parent.parent_ids().to_vec())
.set_description(description)
.write()?;
} else {
let incomplete_commits = tx
.base_workspace_helper()
.find_incomplete([parent.id(), commit.id()])?;
let new_parent = tx
.mut_repo()
.rewrite_commit(command.settings(), &parent)
.rewrite_commit(
command.settings(),
&parent,
incomplete_commits.contains(parent.id()),
)
.set_tree_id(new_parent_tree_id)
.set_predecessors(vec![parent.id().clone(), commit.id().clone()])
.write()?;
// Commit the new child on top of the new parent.
tx.mut_repo()
.rewrite_commit(command.settings(), &commit)
.rewrite_commit(
command.settings(),
&commit,
incomplete_commits.contains(commit.id()),
)
.set_parents(vec![new_parent.id().clone()])
.write()?;
}
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/untrack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub(crate) fn cmd_untrack(
let new_tree_id = tree_builder.write_tree(&store)?;
let new_commit = tx
.mut_repo()
.rewrite_commit(command.settings(), &wc_commit)
.rewrite_commit(command.settings(), &wc_commit, false)
.set_tree_id(new_tree_id)
.write()?;
// Reset the working copy to the new commit
Expand Down
1 change: 1 addition & 0 deletions cli/src/config/revsets.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ latest(
'immutable_heads()' = 'trunk() | tags()'
'immutable()' = '::(immutable_heads() | root())'
'mutable()' = '~immutable()'
'incomplete()' = 'description(exact:"") & empty()'
12 changes: 12 additions & 0 deletions cli/src/revset_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use crate::templater::TemplateRenderer;
use crate::ui::Ui;

const BUILTIN_IMMUTABLE_HEADS: &str = "immutable_heads";
const BUILTIN_INCOMPLETE: &str = "incomplete";

#[derive(Debug, Error)]
pub enum UserRevsetEvaluationError {
Expand Down Expand Up @@ -174,6 +175,17 @@ pub fn parse_immutable_expression(
Ok(heads.union(&RevsetExpression::root()).ancestors())
}

pub fn parse_incomplete_expression(
context: &RevsetParseContext,
) -> Result<Rc<RevsetExpression>, RevsetParseError> {
let (_, _, incomplete_str) = context
.aliases_map()
.get_function(BUILTIN_INCOMPLETE, 0)
.unwrap();

revset::parse(incomplete_str, context)
}

pub(super) fn evaluate_revset_to_single_commit<'a>(
revision_str: &str,
expression: &RevsetExpressionEvaluator<'_>,
Expand Down
7 changes: 7 additions & 0 deletions docs/revsets.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ for a comprehensive list.
*not* change whether a commit is immutable. To do that, edit
`immutable_heads()`.

* `incomplete()`: The set of commits which should have their author timestamp
updated to match the committer timestamp when they are rewritten. Resolves
to `description(exact:"") & empty()` by default (commits which are empty and
have no description). Rebasing commits never updates the author timestamp,
and commits by a different author are never updated. A more Git-like behavior
might be to finalize a commit's author timestamp when a description is set,
which could be achieved using `description(exact:"")`.

## The `all:` modifier

Expand Down
7 changes: 7 additions & 0 deletions lib/src/commit_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ impl CommitBuilder<'_> {
mut_repo: &'repo mut MutableRepo,
settings: &UserSettings,
predecessor: &Commit,
update_author_timestamp: bool,
) -> CommitBuilder<'repo> {
let mut commit = predecessor.store_commit().clone();
commit.predecessors = vec![predecessor.id().clone()];
Expand All @@ -85,6 +86,12 @@ impl CommitBuilder<'_> {
{
commit.author.email = commit.committer.email.clone();
}
if update_author_timestamp
&& commit.author.name == commit.committer.name
&& commit.author.email == commit.committer.email
{
commit.author.timestamp = commit.committer.timestamp.clone();
}
CommitBuilder {
mut_repo,
commit,
Expand Down
Loading

0 comments on commit 28f4b38

Please sign in to comment.